简单的TCP网络程序·单进程

news2024/11/14 11:05:45

目录

文件1:tcpServer.cc

文件2:tcpServer.hpp

1.提出日志概念 -- 在后续完善

日志格式 -- 暂定简单的打印功能

2.创建套接字

SOCK_STREAM -- socket参数

3.bind自己的套接字

4.设置socket 为监听状态 *

新接口1:listen

函数1:initServer()

新接口2:accept *

接口1:read

接口2:write

文件描述符本质是数组下标 -- 有限

ulimit -- 查看本机可以开多少个文件描述符

函数2:serviceIO()

至此基本的功能完成 -- 测试1

准备工作

文件3:tcpClient.cc

文件4:tcpClient.hpp

函数3:initClient()

新接口3:connect *

函数4:start()

至此TCP通信的功能完成 -- 测试2

全部代码

log.hpp

makefile

tcpClient.cc

tcpClient.hpp

tcpServer.cc

tcpServer.hpp


直接代码开整

文件1:tcpServer.cc

准备阶段 -- 目前和UDP是一样的

#include "tcpServer.hpp"
#include <memory>

using namespace std;
using namespace server;

static void Usage(string proc)
{
    cout << "Usage:\n\t" << proc << " local_port\n\n"; // 命令提示符
}

// tcp服务器,启动和 udp server 一模一样
// ./tcpserver lock_port
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]);

    unique_ptr<TcpServer> tsvr(new TcpServer());
    tsvr->initServer();
    tsvr->start();
     
    return 0;
}

文件2:tcpServer.hpp

 

1.提出日志概念 -- 在后续完善

日志格式 -- 暂定简单的打印功能

这个日志在后序完善TCP之后再进行修改,现在只实现简单的打印功能

 

2.创建套接字

 

SOCK_STREAM -- socket参数

3.bind自己的套接字

 

        这里有个细节,我们会发现当我们接受数据的时候是不需要主机转网路序列的,因为关于IO类的接口,内部都帮我们实现了这一功能,这里不帮我们做是因为我们传入的是一个结构体,系统做不到

4.设置socket 为监听状态 *

新接口1:listen

 

底层链接的长度+1,先不管他,在后序讲原理再讲述

 

函数1:initServer()

        void initServer()
        {
            // 1. 创建socket文件套接字对象 -- 流式套接字
            _sock = socket(AF_INET, SOCK_STREAM, 0); // 第三个参数默认 0
            if (_sock < 0)
            {
                logMessage(FATAL, "create socket error");
                exit(SOCKET_ERR);
            }
            logMessage(NORMAL, "create socket success");

            // 2.bind绑定自己的网路信息 -- 注意包含头文件
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(_port);      // 这里有个细节,我们会发现当我们接受数据的时候是不需要主机转网路序列的,因为关于IO类的接口,内部都帮我们实现了这一功能,这里不帮我们做是因为我们传入的是一个结构体,系统做不到
            local.sin_addr.s_addr = INADDR_ANY; // 接受任意ip地址
            if (bind(_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
            {
                logMessage(FATAL, "bind socket error");
                exit(BIND_ERR);
            }
            logMessage(NORMAL, "bind socket success");

            // 3. 设置socket 为监听状态 -- TCP与UDP不同,它先要建立链接之后,TCP是面向链接的,后面还会有“握手”过程
            if (listen(_sock, gbacklog) < 0) // 第二个参数backlog后面再填这个坑
            {
                logMessage(FATAL, "listen socket error");
                exit(LISTEN_ERR);
            }
            logMessage(FATAL, "listen socket success");
        }

注意这里是起始版本,在认识下面的一个接口的时候,需要整改

新接口2:accept *

 

 

 

        一个比喻:就像一家饭店的门口招呼人的张三,当张三从外边招呼人进来的时候,就向饭店里面喊人,让李四去服务客人,但是张三不会进来,又返回去在门口拉客

        因为随着客户端的不断增多,TCP服务器上可能存在多个套接字,就像饭店里面会有多个客人有多个服务员

 

至此我们需要把之前的_sock 修改为 _listensock

 

至此我们获取的sock就是一个文件操作符,可以使用文件操作类的函数进行处理,例如read之类的

接口1:read

从一个文件描述符中读取

接口2:write

 

 

文件描述符本质是数组下标 -- 有限

 

ulimit -- 查看本机可以开多少个文件描述符

 

函数2:serviceIO()

        void serviceIO(int sock)
        {
            // 先用最简单的,读取再写回去
            char buffer[1024];
            while (true)
            {
                ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
                if (n > 0)
                {
                    // 截至目前,我们把读到的数据当作字符串
                    buffer[n] = 0;
                    std::cout << "recv message: " << buffer << std::endl;

                    std::string outbuffer = buffer;
                    outbuffer += "server[echo]";

                    write(sock, outbuffer.c_str(), outbuffer.size()); // 在多路转接的时候再详谈write的返回值
                }
                else if(n == 0)
                {
                    // 代表client退出 -- 把它想象成一个建立好的管道,客户端不写了,并且把它的文件描述符关了,读端就会像管道一样读到 0 TCP同理
                    logMessage(NORMAL, "client quit, me too!");
                }
            }
        }

至此基本的功能完成 -- 测试1

 

准备工作

文件3:tcpClient.cc

调用逻辑 

#include "tcpClient.hpp"
#include <memory>

using namespace std;

static void Usage(string proc)
{
    cout << "Usage:\n\t" << proc << " serverip serverport\n\n"; // 命令提示符
}

// ./tcpclient serverip serverport  调用逻辑
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    string serverip = argv[1];
    uint16_t serverport = atoi(argv[2]);

    unique_ptr<TcpClient> tcli(new TcpClient(serverip, serverport));
    tcli->initClient();
    tcli->start();

    return 0;
}

文件4:tcpClient.hpp

函数3:initClient()

    void initClient()
    {
        // 1. 创建socket
        _sock = socket(AF_INET, SOCK_STREAM, 0);
        if (_sock < 0)
        {
            // 客户端也可以有日志,不过这里就不再实现了,直接打印错误
            std::cout << "socket create error" << std::endl;
            exit(2);
        }

        // 2. tcp的客户端要不要bind? 要的! 但是不需要显示bind,这里的client port要让OS自定!
        // 3. 要不要listen? -- 不需要!客户端不需要建立链接
        // 4. 要不要accept? -- 不要!
        // 5. 要什么? 要发起链接!
    }

新接口3:connect *

 

函数4:start()

 void start()
    {
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(_serverport);
        server.sin_addr.s_addr = inet_addr(_serverip.c_str());

        if (connect(_sock, (struct sockaddr *)&server, sizeof(server)) != 0)
        {
            std::cerr << "socket connect error" << std::endl;
        }
        else
        {
            std::string msg;
            while (true)
            {
                std::cout << "Enter# ";
                std::getline(std::cin, msg);
                write(_sock, msg.c_str(), msg.size());

                char buffer[NUM];
                int n = read(_sock, buffer, sizeof(buffer) - 1);
                if (n > 0)
                {
                    // 目前我们把读到的数据当成字符串, 截至目前
                    buffer[n] = 0;
                    std::cout << "Server回显# " << buffer << std::endl;
                }
                else
                {
                    break;
                }
            }
        }
    }

至此TCP通信的功能完成 -- 测试2

        但是至此,我们所写的不过是一个单进程版的,所以会出现下面的情况,后续需要进一步修改成为多进程形式的

 

全部代码

log.hpp

#pragma once

#include <iostream>
#include <string>

// 定义五种不同的信息
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3     //一种不影响服务器的错误
#define FATAL 4     //致命错误

void logMessage(int level, const std::string message)
{
    // 格式如下
    // [日志等级] [时间戳/时间] [pid] [message]
    // [FATAL0] [2023-06-11 16:46:07] [123] [创建套接字失败]

    // 暂定
    std::cout << message << std::endl;
}

makefile

cc=g++
.PHONY:all
all:tcpserver tcpclient

tcpclient:tcpClient.cc
	$(cc) -o $@ $^ -std=c++11

tcpserver:tcpServer.cc
	$(cc) -o $@ $^ -std=c++11
 
.PHONY:clean
clean:
	rm -f tcpserver tcpclient

 

tcpClient.cc

#include "tcpClient.hpp"
#include <memory>

using namespace std;

static void Usage(string proc)
{
    cout << "Usage:\n\t" << proc << " serverip serverport\n\n"; // 命令提示符
}

// ./tcpclient serverip serverport  调用逻辑
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    string serverip = argv[1];
    uint16_t serverport = atoi(argv[2]);

    unique_ptr<TcpClient> tcli(new TcpClient(serverip, serverport));
    tcli->initClient();
    tcli->start();

    return 0;
}

tcpClient.hpp

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#define NUM 1024

class TcpClient
{
public:
    TcpClient(const std::string &serverip, const uint16_t &port)
        : _sock(1), _serverip(serverip), _serverport(port)
    {
    }
    void initClient()
    {
        // 1. 创建socket
        _sock = socket(AF_INET, SOCK_STREAM, 0);
        if (_sock < 0)
        {
            // 客户端也可以有日志,不过这里就不再实现了,直接打印错误
            std::cout << "socket create error" << std::endl;
            exit(2);
        }

        // 2. tcp的客户端要不要bind? 要的! 但是不需要显示bind,这里的client port要让OS自定!
        // 3. 要不要listen? -- 不需要!客户端不需要建立链接
        // 4. 要不要accept? -- 不要!
        // 5. 要什么? 要发起链接!
    }

    void start()
    {
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(_serverport);
        server.sin_addr.s_addr = inet_addr(_serverip.c_str());

        if (connect(_sock, (struct sockaddr *)&server, sizeof(server)) != 0)
        {
            std::cerr << "socket connect error" << std::endl;
        }
        else
        {
            std::string msg;
            while (true)
            {
                std::cout << "Enter# ";
                std::getline(std::cin, msg);
                write(_sock, msg.c_str(), msg.size());

                char buffer[NUM];
                int n = read(_sock, buffer, sizeof(buffer) - 1);
                if (n > 0)
                {
                    // 目前我们把读到的数据当成字符串, 截至目前
                    buffer[n] = 0;
                    std::cout << "Server回显# " << buffer << std::endl;
                }
                else
                {
                    break;
                }
            }
        }
    }
    ~TcpClient()
    {
        if(_sock >= 0) close(_sock);    //不写也行,因为文件描述符的生命周期随进程,所以进程退了,自然也就会自动回收了
    }

private:
    int _sock;
    std::string _serverip;
    uint16_t _serverport;
};

tcpServer.cc

#include "tcpServer.hpp"
#include <memory>

using namespace server;
using namespace std;

static void Usage(string proc)
{
    cout << "Usage:\n\t" << proc << " local_port\n\n"; // 命令提示符
}

// tcp服务器,启动上和udp server一模一样
// ./tcpserver local_port
int main(int argc, char *argv[])
{ 
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]);

    unique_ptr<TcpServer> tsvr(new TcpServer(port));
    tsvr->initServer();
    tsvr->start();

    return 0;
}

tcpServer.hpp

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "log.hpp"

namespace server
{
    enum
    {
        USAGE_ERR = 1,
        SOCKET_ERR,
        BIND_ERR,
        LISTEN_ERR

    };

    static const uint16_t gport = 8080;
    static const int gbacklog = 5; // 10、20、50都可以,但是不要太大比如5千,5万

    class TcpServer
    {
    public:
        TcpServer(const uint16_t &port = gport) : _listensock(-1), _port(port)
        {
        }
        void initServer()
        {
            // 1. 创建socket文件套接字对象 -- 流式套接字
            _listensock = socket(AF_INET, SOCK_STREAM, 0); // 第三个参数默认 0
            if (_listensock < 0)
            {
                logMessage(FATAL, "create socket error");
                exit(SOCKET_ERR);
            }
            logMessage(NORMAL, "create socket success");

            // 2.bind绑定自己的网路信息 -- 注意包含头文件
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(_port);      // 这里有个细节,我们会发现当我们接受数据的时候是不需要主机转网路序列的,因为关于IO类的接口,内部都帮我们实现了这一功能,这里不帮我们做是因为我们传入的是一个结构体,系统做不到
            local.sin_addr.s_addr = INADDR_ANY; // 接受任意ip地址
            if (bind(_listensock, (struct sockaddr *)&local, sizeof(local)) < 0)
            {
                logMessage(FATAL, "bind socket error");
                exit(BIND_ERR);
            }
            logMessage(NORMAL, "bind socket success");

            // 3. 设置socket 为监听状态 -- TCP与UDP不同,它先要建立链接之后,TCP是面向链接的,后面还会有“握手”过程
            if (listen(_listensock, gbacklog) < 0) // 第二个参数backlog后面再填这个坑
            {
                logMessage(FATAL, "listen socket error");
                exit(LISTEN_ERR);
            }
            logMessage(NORMAL, "listen socket success");
        }

        void start()
        {
            for (;;) // 一个死循环
            {
                // 4. server 获取新链接
                // sock 和client 进行通信的fd
                struct sockaddr_in peer;
                socklen_t len = sizeof(peer);
                int sock = accept(_listensock, (struct sockaddr *)&peer, &len);
                if (sock < 0)
                {
                    logMessage(ERROR, "accept error, next"); // 这个不影响服务器的运行,用ERROR,就像张三不会因为没有把人招呼进来就不干了
                    continue;
                }
                logMessage(NORMAL, "accept a new link success");
                std::cout << "sock: " << sock << std::endl;

                // 5. 这里就是一个sock, 未来通信我们就用这个sock, 面向字节流的,后续全部都是文件操作!
                // 我们就可以直接使用read之类的面向字节流的操作都行
                // version 1
                serviceIO(sock);
                close(sock); // 走到这里就说明客户端已经关闭
                             // 对一个已经使用完毕的sock,我们要关闭这个sock,要不然会导致,文件描述符会越来越少,因为文件描述符本质就是一个数组下标
                             // 只要是数组下标就会有尽头,提供服务的上限 就等于文件描述符的上限
                             // 对一个已经使用完毕的sock,我们要关闭这个sock,要不然会导致,文件描述符泄漏
            }
        }

        void serviceIO(int sock)
        {
            // 先用最简单的,读取再写回去
            char buffer[1024];
            while (true)
            {
                ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
                if (n > 0)
                {
                    // 截至目前,我们把读到的数据当作字符串
                    buffer[n] = 0;
                    std::cout << "recv message: " << buffer << std::endl;

                    std::string outbuffer = buffer;
                    outbuffer += "server[echo]";

                    write(sock, outbuffer.c_str(), outbuffer.size()); // 在多路转接的时候再详谈write的返回值
                }
                else if (n == 0)
                {
                    // 代表client退出 -- 把它想象成一个建立好的管道,客户端不写了,并且把它的文件描述符关了,读端就会像管道一样读到 0 TCP同理
                    logMessage(NORMAL, "client quit, me too!");
                    break;
                }
            }
        }

        ~TcpServer() {}

    private:
        int _listensock; // 修改二:改为listensock 不是用来进行数据通信的,它是用来监听链接到来,获取新链接的!
        uint16_t _port;
    };

} // namespace server

未完持续更新中……

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/635590.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

IIC协议总结

IIC&#xff08;Inter-Integrated Circuit) &#xff0c;简单说就是IC&#xff08;芯片&#xff09;之间通信的总线。所谓总线&#xff0c;就是各个器件都并联到一组公共的线路上&#xff0c;然后共用这条线路来传输数据。总线的英文名为BUS&#xff0c;这是个形象的名字&#…

chatgpt赋能python:Python如何取三位数的每一位

Python如何取三位数的每一位 作为一门广泛应用的编程语言&#xff0c;Python在数值相关处理方面也十分得心应手。取三位数的每一位是一个常见的需求&#xff0c;那么在Python中该如何实现呢&#xff1f; 1. 数字字符串切片 Python中的字符串有索引和切片功能&#xff0c;我们…

chatgpt赋能python:Python如何命名输出图片名字

Python 如何命名输出图片名字 Python 是一种非常流行的编程语言。它非常灵活&#xff0c;可以用于各种应用场景。其中一个应用是图像处理。在 Python 中&#xff0c;我们可以使用不同的库来读取和处理图像。但是&#xff0c;当处理大量图片时&#xff0c;给每个图片命名会变得…

UDS关于0x37服务退出传输学习笔记

1.服务说明 客户端使用此服务来终止客户端和服务器之间的数据传输&#xff08;上传或下载&#xff09;。 2.请求消息 2.1请求消息子功能参数$Level&#xff08;LEV_&#xff09;定义 此服务不使用子函数参数。 2.2请求消息数据参数定义 transferRequestParameterRecord&a…

C++技能 - 详解const的几种使用【再也不迷糊了,干货还是蛮多的】

系列文章目录 C高性能优化编程系列 深入理解软件架构设计系列 高级C并发线程编程 C技能系列 期待你的关注哦&#xff01;&#xff01;&#xff01; 生活就是上帝发给你的一张手牌&#xff0c;无论多烂&#xff0c;你都得拿着。 Life is god give you a hand, no matter ho…

RK3588平台开发系列讲解(以太网篇)MDIO底层驱动

平台内核版本安卓版本RK3588Linux 5.10Android 12文章目录 一、mdiobus总线二、mdiobus的注册沉淀、分享、成长,让自己和他人都能有所收获!😄 一、mdiobus总线 mdio bus是mdio管里phy寄存器的总线,此总线非设备驱动模型之总线。mdiobus在内核中用mii_bus结构体描述,mii_b…

基于51单片机的电子钟Protues仿真设计

一、设计背景 电子钟是指利用数字电路或单片机等现代电子技术来实现时间计量和显示的钟表。相较于传统机械钟、石英钟等时钟&#xff0c;电子钟具有精度高、音响小、易于制造和调节等优点&#xff0c;同时也由于其美观大方的外观设计而成为了家居装饰中不可或缺的一部分。 其…

初探react中使用MongoDB

MongoDB介绍与安装 什么是MongoDB 来自于英文单词“Humongous”&#xff0c;中文含义表示“庞大”面向文档存储的开源数据库由C编写&#xff0c;支持多种语言连接 为什么要用MongoDB 性能好&#xff08;内存计算&#xff09;大规模数据存储&#xff08;可拓展性&#xff09…

Cracking C++(9): 编译选项的设置

文章目录 1. 目的2. 生成 hello 可执行程序的过程3. 编译选项4. overlook 项目简介4.1 基本用法4.2 典型例子&#xff1a; 函数缺少返回值 1. 目的 对应到 hackingcpp 网站上 Hello World 这一节内容的笔记和个人的拓展。 2. 生成 hello 可执行程序的过程 #include <iost…

牛客网专项练习——C语言错题集(3)

文章目录 函数的缺省返回值类型找不同数组指针 函数指针const 与 指针函数里操作外面传进来的数组 函数的缺省返回值类型 “在C语言中&#xff0c;凡不加返回值类型限定的函数&#xff0c;就会被编译器作为返回整型值处理。” 这是初学学 C 语言时有接触到的一个概念&#xf…

RK3588平台开发系列讲解(进程篇)进程task_struct结构体详解(一)

平台内核版本安卓版本RK3588Linux 5.10Android 12文章目录 一、任务 ID二、信号处理三、任务状态四、进程调度沉淀、分享、成长,让自己和他人都能有所收获!😄 📢 Linux 里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构 task_stru…

1.uniapp可视化操作-diygw

DAY1-uniapp可视化操作:DIY可视化-拖拽设计1天搞定主流小程序环境安装 文章目录 DAY1-uniapp可视化操作:DIY可视化-拖拽设计1天搞定主流小程序环境安装前言一、DIY可视化桌面客户端安装:二、小皮管理面板phpstudy(或者宝塔桌面)三、phpstorm开发工具:四、Vs Code安装:五、hbuil…

《Java Web轻量级整合开发入门》学习笔记

轻量级Java Web整合开发 第一章 轻量级Java Web开发概述 1.2 java web 开发概述 1.JSP是一种编译执行的前台页面技术。对于每个JSP页面&#xff0c;Web服务器都会生成一个相应的Java文件&#xff0c;然后再编译该Java文件&#xff0c;生成相应的Class类型文件。在客户端访问到…

chatgpt赋能python:Python怎么合并字典

Python怎么合并字典 在Python编程中&#xff0c;字典是一种非常有用的数据结构。它由一组键值对组成&#xff0c;每个键值对都是一个键和对应的值。有时候&#xff0c;我们需要将两个或多个字典合并为一个大的字典。本文将介绍Python中合并字典的方法。 方法一&#xff1a;使…

[架构之路-212]- 需求- UML需求建模:用例图、ER图/概念类图、流程图、序列图、状态机图

目录 前言&#xff1a; 什么是需求建模 1. 用例图 1.1 用例图 1.1.1 组件 1.1.2 用例细化与用例关系 1.2 用例规约 2. ER图/概念类图 3. 跨角色流程图&#xff08;串行、协同&#xff09; 4. 活动图&#xff08;并行、协同&#xff09; 5. 状态机图 6. 时序图 前言…

Letcode经典黄题——三数之和

还是那句话&#xff0c;一个题思路很重要&#xff0c;没有思路等于无头苍蝇乱撞 题目要求&#xff1a;给你一个整数数组 nums &#xff0c;判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i ! j、i ! k 且 j ! k &#xff0c;同时还满足 nums[i] nums[j] nums[k] 0 …

chatgpt赋能python:Python怎么变黑?探索Python黑科技的奥秘

Python怎么变黑&#xff1f;探索Python黑科技的奥秘 如果你在网上搜索“Python怎么变黑”&#xff0c;你会看到很多关于Python黑客技术的讨论。但是&#xff0c;这篇文章将会聚焦于Python如何变得更“黑”&#xff0c;即在SEO优化方面如何利用Python。 Python在SEO优化中的作…

华硕天选4原装Windows11系统带ASUSRECOVERY恢复工厂模式安装

华硕工厂恢复系统 &#xff0c;安装结束后带隐藏分区以及机器所有驱动软件,奥创Myasus Recovery 文件地址https://pan.baidu.com/s/1Pq09oDzmFI6hXVdf8Vqjqw?pwd3fs8 提取码:3fs8 文件格式&#xff1a;5个底包(HDI KIT COM MCAFEE EDN) 1个引导工具TLK 支持ASUSRECOVERY型…

chatgpt赋能python:Python和Envi:完美结合的最佳方案

Python和Envi&#xff1a;完美结合的最佳方案 如果你正在寻找一种可靠的方法来将Python编程与Envi软件结合起来&#xff0c;那么你来对地方了&#xff01;在本篇文章中&#xff0c;我们将介绍如何使用Python编程语言和Envi软件相互结合&#xff0c;从而让你的数据分析和地理信…

价值8k的电子书小程序源码+前端(含教程)

&#x1f388; 限时活动领体验会员&#xff1a;可下载程序网创项目短视频素材 &#x1f388; &#x1f389; 有需要的朋友记得关赞评&#xff0c;阅读文章底部来交流&#xff01;&#xff01;&#xff01; &#x1f389; ✨ 源码介绍 价值8k的电子书小程序源码_附完整教程 无意…