Linux学习之自定义协议

前言:

首先对于Tcp的socket的通信,我们已经大概了解了,但是其中其实是由一个小问题,我们目前是不得而知得,在我们客户端与服务端连接成功后,服务端读取来自客户端得消息,我们使用的是函数read,通过来读取accept之后的文件,从而获取信息,可是我们怎么去知道每次读取都是一个完成的报文呢?

在用户端与服务端通信的时候,用户首先写信息到发送缓冲区当中,经过网络推送到接收缓冲区,之后服务端从接收缓冲区中读取数据。在这个过程中,其实都是有Tcp自主控制的,这也就是他为什么叫传输控制协议,tcp会处理在发送过程中所有遇到的问题:发什么,什么时候发,出错了怎么办?这里我们用到的接口,如write,read,accept等都是实现用户到内核的数据拷贝。

如何保证,数据的发送是准确无误的,这就取决于tcp,而tcp怎么保证?这就需要协议的定制。

目录

前言:

协议的定制

套接字文件

protocol.hpp

Calcultor.hpp

客户端:

服务端:

主运行函数:


协议的定制

协议是一种约定,在进行socket通信时,读写都是用字符串,那么如果要传输的数据是结构化数据呢?

我们以接受发消息为例:

我们平常发的消息,不仅仅只有我们的消息的内容,其实还有时间,名字,和消息。

一般,用户发出消息,系统会将该消息写到一个固定的结构体里,在将该结构化数据转化为字符串,再通过网络发送出去,接受的时候,还是需要将字符串先转化为结构数据从,之后再访问其中成员获取消息给用户。

数据的结构化的过程我们可以简单的理解为协议的封装。

现在我们就通过编写一个网络计算机为例,将所有的知识结合起来:

套接字文件

首先是套接字文件,里面包含了客户端与服务端需要直接调用的方法,例如创建套接字,绑定,监听,连接,接受等。

//网络接口
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<unistd.h>
#include <string.h>
#include<strings.h>

const int backlog=10;
const int defaultfd=-1;
enum 
{
    CREATEERROR=1,
    BINDERROR=2,
    LISTENERROR=3,
    ACCEPTERROR=4
};
class Sock
{
      public:
            Sock(const int socket=defaultfd):_sockfd()
            {}
            void Createsockfd()
            {
                //1.创建套接字文件描述符
                _sockfd=socket(AF_INET,SOCK_STREAM,0);
                if(_sockfd<0)
                {
                    std::cout<<"创建失败"<<",错误码:"<<errno<<",错误信息"<<strerror(errno)<<std::endl;
                    exit(CREATEERROR);
                }
                std::cout<<"创建成功"<<std::endl;
            }
            void Bind(uint16_t port)
            {
                //初始化sockaddr_in
                struct sockaddr_in local;
                bzero(&local, sizeof(local));
                local.sin_family=AF_INET;
                local.sin_port=htons(port);
                local.sin_addr.s_addr=INADDR_ANY;   
                //2.绑定端口号
                socklen_t len=sizeof(local);
                int n=bind(_sockfd,(const struct sockaddr*)&local,len);
                if(n<0)
                {
                    std::cout<<"绑定失败"<<",错误码:"<<errno<<",错误信息"<<strerror(errno)<<std::endl;
                    exit(BINDERROR);
                }
                std::cout<<"端口号绑定成功"<<std::endl;

            }
            void Listen()
            {
                //3.将套接字设置为监听状态
                int l=listen(_sockfd,backlog);
                if(l<0)
                {
                    std::cout<<"监听失败"<<",错误码:"<<errno<<",错误信息"<<strerror(errno)<<std::endl;
                    exit(LISTENERROR);
                }
                std::cout<<"设置监听成功"<<std::endl;

            }            
           void Connect(std::string &ip,uint16_t &port)
           {
                //我去连接哪一个服务端

                    //获取服务端信息
                struct sockaddr_in remote;
                bzero(&remote, sizeof(remote));
                remote.sin_family=AF_INET;
                remote.sin_port=htons(port);
                inet_pton(AF_INET, ip.c_str(),&(remote.sin_addr));
                //建立连接
                socklen_t len=sizeof(remote);
                int n=connect(_sockfd,(const struct sockaddr*)&remote,len);
                if(n<0)
                {
                    std::cout<<"连接失败"<<",错误码:"<<errno<<",错误信息"<<strerror(errno)<<std::endl;
                    exit(ACCEPTERROR);
                }
                std::cout<<"连接成功"<<std::endl;
              
              

           }
           int Accept(std::string *clientip,uint16_t* clientport)
           {
                
                struct sockaddr_in client;
                socklen_t clientlen=sizeof(client);
                std::cout<<"测试1。。。。。。"<<std::endl;
                int newfd=accept(_sockfd,(struct sockaddr*)&client,&clientlen);
                
                if(newfd<0)
                {
                    std::cout<<"接收失败"<<",错误码:"<<errno<<",错误信息"<<strerror(errno)<<std::endl;
                    return -1;
                }
                std::cout<<"测试2。。。。。。"<<std::endl;
                //获取ip地址与端口号
                char _ptr[32];
                inet_ntop(AF_INET, &client.sin_addr,_ptr,sizeof(_ptr));
                *clientip=std::string(_ptr);
                *clientport=ntohs(client.sin_port);
                std::cout<<"接收成功"<<std::endl;
                return newfd;
           }
             
            int Fd()
            {
                return _sockfd;
            }
            ~Sock()
            {
            
            }
            void Close()
            {
                //孙子进程提供服务

                close(_sockfd);
            }

    private:

            int _sockfd;
};

我们来看看协议封装的头文件:

protocol.hpp

主要是序列化与反序列化

#pragma once
#include <iostream>
#include <string>
using namespace std;
const std::string blankspace = " ";
const string protocol_str = "/n";
//这里我们计算的字符串 10 * 10 会被Encode为 5/n10 * 10/n
std::string Encode(std::string &content)//编码格式为 len/nx op y/n
{
    std::string package = std::to_string(content.size());
    package += protocol_str;
    package += content;
    package += protocol_str;

    return package;
}
//反之去掉/n 与长度 变为 x op y格式
bool Decode(std::string &package, std::string *content)
{
    std::size_t pos = package.find(protocol_str);
    if(pos == std::string::npos) return false;
    std::string len_str = package.substr(0, pos);
    std::size_t len = std::stoi(len_str);
    // package = len_str + content_str + 2
    std::size_t total_len = len_str.size() + len + 2;
    if(package.size() < total_len) return false;

    *content = package.substr(pos+1, len);
    // earse 移除报文 package.erase(0, total_len);
    package.erase(0, total_len);

    return true;
}

// 定制协议,这里我们打算搞得是网络计算器
// 请求
class Request
{
public:
    Request(int data1, int data2,char c) : _x(data1), _y(data2),_op(c)
    {
    }
    // 报文的读格式为 len/n a op b
    bool Serialize(string *out) // 序列化
    {
        // 转化为字符串
        // 构建报文的有效载荷
        std::string s = std::to_string(_x);
        s += blankspace;
        s += _op;
        s += blankspace;
        s += std::to_string(_y);
        *out = s;
        return true;
        //序列化  将已知的x,op,y进行第一层封装为 x op y 格式的字符串
        
    }
    bool Deserialize(const string &package) // 反序列化   已经定义好了是 len\nx op y
    {
            size_t pos=package.find(blankspace);
            if(pos==std::string::npos)
            {
                return false;
            }
            std::string x=package.substr(0,pos);

            size_t pot=package.rfind(blankspace);
            if(pot==std::string::npos)
            {
                return false;
            }
            std::string y=package.substr(pot+1);

            if(pos+2!=pot)
            {
                return false;
            }
            _op=package[pos+1];
            _x=std::stoi(x.c_str());
            _y=std::stoi(y.c_str());
            //通过反序列化取得 字符 x,y,op 并转化相应类型

    }
    ~Request()
    {
    }

public:
    int _x;
    int _y;
    char _op; // + = * / %
};
// 应答
class Response
{
public:
        Response(int result, int code) : _result(result), _code(code)
        {
        }
        ~Response()
        {
        }
    // 序列化
        bool Serialize(string *out)
        {
            //"len"\n"result code"
            // 构建有效的报文载荷
            std::string s = std::to_string(_result);
            s += blankspace;
            s += std::to_string(_code);
            *out = s;
            return true;
        }
    // 反序列化
        bool Deserialize(const std::string &in) // len\n result code 
        {
            std::size_t pos = in.find(blankspace);
             if (pos == std::string::npos)
            return false;
            std::string part_left = in.substr(0, pos);
            std::string part_right = in.substr(pos+1);

            _result = std::stoi(part_left);
            _code = std::stoi(part_right);

            return true;
        }

public:
    int _result;
    int _code; // 错误码,非0具体实际表明错误原因
};

之后根据我们初始化构造传入的参数,进行编码,传入到定制对象Request中,进行序列化,之后完成序列化,取得定制对象并传入 函数Calculator中进行计算,再根据返回的结果在进行Response对象的定制,序列化,再编码,作为报文字符串可以进行发了,服务端之后接受到到再解码,反序列化,即可。

计算转化头文件

Calcultor.hpp

主要是将传入的字符串,编码,序列化,之后调用计算,再解码,反序列化。实现计算过程。

#pragma once
#include<iostream>
#include"protocol.hpp"

class ServerCal{

    public:
    ServerCal()
    {}
    Response CalculatorOperator(const Request &req)
    {
        Response resp(0, 0);
        switch (req.op)
        {
        case '+':
            resp.result = req.x + req.y;
            break;
        case '-':
            resp.result = req.x - req.y;
            break;
        case '*':
            resp.result = req.x * req.y;
            break;
        case '/':
        {
            if (req.y == 0)
                resp.code = Div_Zero;
            else
                resp.result = req.x / req.y;
        }
        break;
        case '%':
        {
            if (req.y == 0)
                resp.code = Mod_Zero;
            else
                resp.result = req.x % req.y;
        }
        break;
        default:
            resp.code = Other_Oper;
            break;
        }

        return resp;
    }
    std::string Calculator(std::string &package)
    {
        //首先把内容转成报文格式
        std::string content;
        bool can=Decode(package,&content);//把传进来的字符解码 len/n x op y
        if(!can) 
            return ;
        Request req;
        can=req.Deserialize(content);//反序列化 x op y
        if(!can)
        return ;

        content="";
        Response resp=CalculatorOperator(req);//进行计算req.x,req.y,req.op
        resp.Serialize(&content);//变为 result code
        content=Encode(content);//变为 len/n result code
        return content;
      
    }
    private:


};

协议的定制就是约定,无论客户端还是服务端都要以这种方式来接受发报文,不符合该要求的是不会接收的。

可以看到序列与反序列化基本上都是字符串操作,每次这样写有点麻烦,实际上,再开发过程中,有现成的序列与反序列化的方法供我们使用,一般就是json,protobuf.

//json
bool Serialize(std::string *out)
    {

        Json::Value root;
        root["x"] = x;
        root["y"] = y;
        root["op"] = op;
        // Json::FastWriter w;
        Json::StyledWriter w;
        *out = w.write(root);
        return true;
    }
bool Deserialize(const std::string &in) // "x op y"
    {

       Json::Value root;
        Json::Reader r;
        r.parse(in, root);
        x = root["x"].asInt();
        y = root["y"].asInt();
        op = root["op"].asInt();
        return true;

    }
bool Serialize(std::string *out)
    {

        Json::Value root;
        root["result"] = result;
        root["code"] = code;
        // Json::FastWriter w;
        Json::StyledWriter w;
        *out = w.write(root);
        return true;
    }
    bool Deserialize(const std::string &in) // "result code"
    {

        Json::Value root;
        Json::Reader r;
        r.parse(in, root);

        result = root["result"].asInt();
        code = root["code"].asInt();
        return true;
    }

客户端:

#include<iostream>
#include<string>
#include<unistd.h>
#include<time.h>
#include<assert.h>
#include"Protocol.hpp"
#include"Socket.hpp"
void USage(char *s)
{
    std::cout<<"Usage:"<<s<<"<ip> <port>"<<std::endl;
}
int main(int argc,char*argv[])
{
    if(argc!=3)
    {
       USage(argv[0]);
       exit(0);
    }
    uint16_t port=stoi(argv[2]);
    std::string ip =argv[1];
    Sock sockfd;
    sockfd.Createsockfd();
    sockfd.Connect(ip, port);
    

    int cnt=0;
    srand(time(NULL)^getpid());
    const std::string opers = "+-*/%=-=&^";
    while(cnt <= 10)
    {
        std::cout << "===============第" << cnt << "次测试....., " << "===============" << std::endl;
        int x = rand() % 100 + 1;
        usleep(1234);
        int y = rand() % 100;
        usleep(4321);
        char oper = opers[rand()%opers.size()];
        Request req(x, y, oper);

        std::string package;
        req.Serialize(&package);

        package = Encode(package);

        write(sockfd.Fd(), package.c_str(), package.size());
        
        cout<<"发送字符串:"<<package<<endl;
        char buffer[128];
        std::string inbuffer_stream;
        ssize_t n = read(sockfd.Fd(), buffer, sizeof(buffer)); 
        if(n > 0)
        {
            buffer[n] = 0;
            inbuffer_stream += buffer; // "len"\n"result code"\n
            std::cout << inbuffer_stream << std::endl;
            std::string content;
            bool r = Decode(inbuffer_stream, &content); // "result code"
            assert(r);

            Response resp;
            r = resp.Deserialize(content);
            assert(r);

            cout<<"接受字符串:"<<content<<endl;
        }

        std::cout << "=================================================" << std::endl;
        sleep(1);

        cnt++;
    }
    sockfd.Close();
    return 0;

   
}

服务端:

#pragma once
#include<iostream>
#include<functional>
#include<strings.h>
#include <signal.h>
#include"Socket.hpp"
using namespace std;
const uint16_t defaultport=8080;
const string defaultip="127.0.0.1";
const int defaultsockfd=-1;
using func_t = std::function<std::string(std::string &package)>;
class Tcpserver
{

  public:
         Tcpserver(uint16_t port ,func_t callback) : _port(port), _callback(callback)
         {}

         void InitServer()
         {
            _listensock.Createsockfd();
            _listensock.Bind(_port);
            _listensock.Listen();
         }

         void Start()
         {
            
            signal(SIGCHLD,SIG_IGN); 
            signal(SIGPIPE,SIG_IGN); 
            while(true)
            {
                string clientip;
                uint16_t clientport;
                int sockfd=_listensock.Accept(&clientip,&clientport);//回调初始化地址与端口号
                if(sockfd<0)
                {
                    continue;
                }   
                //孙子进程提供服务,可并发访问。
                if(fork()==0)
                {
                        //关闭文件描述符不影响文件缓冲区,防止文件描述符不够用
                        _listensock.Close(); 
                        //service
                        std::string buffoutput;
                        while(true)
                        {
                            char buff[1024];
                            ssize_t n=read(sockfd, buff, sizeof(buff));
                            if(n==0)
                            {
                                std::cout<<"读取消息失败"<<"错误码"<<errno<<"错误原因"<<strerror(errno)<<std::endl;
                                break;
                            }else if(n>0)
                            {
                                std::cout<<"读取消息成功:"<<buff<<",进行报文解读.........."<<std::endl;
                                buff[n]=0;
                                buffoutput+=std::string(buff);
                                std::cout<<buffoutput<<endl;
                                //可能有一堆请求
                                while(true)
                                {
                                    std::string info=_callback(buffoutput);//回调给函数DoCalculator
                                    //为空就继续读
                                    if(info.empty()) 
                                    {
                                        break;
                                    }
                                    std::cout<< "debug, response:"<< info.c_str()<<std::endl;
                                    std::cout<<"debug:"<<buffoutput.c_str()<<std::endl;
                                    //向客户写回信息,向缓冲区里写
                                    write(sockfd,info.c_str(),info.size());
                                }
                                
                            }else 
                            {
                                break;
                            }
                            
                        }

                        exit(0);
                }
                close(sockfd);


            }

         }

         ~Tcpserver()
         {
 
         }

  private:
         Sock _listensock;
         uint16_t _port;
        func_t _callback;
};

主运行函数:

#include"Calculator.hpp"
#include"ServerCalculator.hpp"
#include <unistd.h>
void Hlper(char *s)
{
    std::cout<<"please enter correct command in '"<<s<<" port[1024+]'"<<std::endl;
}
int main(int argc,char *argv[])
{
    if(argc!=2)
    {
        Hlper(argv[0]);
        exit(1);
    }
    uint16_t port=stoi(argv[1]);
    ServerCal Calculator;
    Tcpserver *Cal=new Tcpserver(port,std::bind(&ServerCal::DoCalculator,&Calculator,std::placeholders::_1));//这里用的是包装器bind
    Cal->InitServer();
    Cal->Start();
                                     

    return 0;

}

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

电子科技大学链时代工作室招新题C语言部分---题号H

1. 题目 最有操作的一道题&#xff0c;有利于对贪心算法有个初步了解。 这道题的开篇向我们介绍了一个叫汉明距离的概念。 汉明距离指的就是两个相同长度的字符串的不同字符的个数。 例如&#xff0c;abc和acd&#xff0c;b与c不同&#xff0c;c与d不同&#xff0c;所以这两个…

【Leetcode每日一题】 递归 - Pow(x, n)(难度⭐⭐)(40)

1. 题目解析 题目链接&#xff1a;50. Pow(x, n) 这个问题的理解其实相当简单&#xff0c;只需看一下示例&#xff0c;基本就能明白其含义了。 2.算法原理 在这个算法中&#xff0c;递归函数的任务是求出 x 的 n 次方。那么&#xff0c;这个函数是怎么工作的呢&#xff1f;它…

基于springboot的大学生租房平台系统

技术&#xff1a;springbootmysqlvue 一、系统背景 互联网发展至今&#xff0c;无论是其理论还是技术都已经成熟&#xff0c;而且它广泛参与在社会中的方方面面。它让信息都可以通过网络传播&#xff0c;搭配信息管理工具可以很好地为人们提供服务。针对大学生租房信息管理混乱…

RocketMQ学习笔记四(黑马)项目

课程地址&#xff1a; 1.Rocket第二章内容介绍_哔哩哔哩_bilibili &#xff08;视频35~88&#xff0c;搭建了一个电商项目&#xff0c;8h&#xff09; 待学&#xff0c;待完善。 项目安装&#xff08;打包&#xff09;的命令&#xff1a;&#xff08;注意-D后有空格&#xff09…

数据挖掘之关联规则

“啤酒和尿布的荣誉” 概念 项 item&#xff1a;单个的事物个体 &#xff0c;I{i1,i2…im}是所有项的集合&#xff0c;|I|m是项的总数项集&#xff08;item set)/模式&#xff08;pattern)&#xff1a;项的集合&#xff0c;包含k个项的项集称为k-项集数据集(data set)/数据库…

NCV8664CST50T3G稳压器芯片中文资料规格书PDF数据手册引脚图图片价格参数

产品概述&#xff1a; NCV8664C 是一款精确 3.3 V 和 5.0 V 固定输出&#xff0c;低漏集成式电压稳压器&#xff0c;输出电流能力为 150 mA。对轻型负载电流消耗的精心管理&#xff0c;并结合低漏工艺&#xff0c;实现了 22 A 的典型静态电流。输出电压精度在 2.0&#xff05;…

TikTok运营要用什么代理IP?如何分辨?

对于运营TikTok的从业者来说&#xff0c;IP的重要性自然不言而喻。 在其他条件都正常的情况下&#xff0c;拥有一个稳定&#xff0c;纯净的IP&#xff0c;你的视频起始播放量很可能比别人高出不少&#xff0c;而劣质的IP轻则会限流&#xff0c;重则会封号。那么&#xff0c;如何…

Linux 文件系统:重定向、缓冲区

目录 一、重定向 1、输出重定向 2、输入重定向 3、追加重定向 4、dup2 系统调用 二、理性理解Linux系统下“一切皆文件” 了解硬件接口 三、缓冲区 1、为什么要有缓冲区? 2、刷新策略 3、缓冲模式改变导致发生写时拷贝 未创建子进程时 创建子进程时 使用fflush…

贾志杰“大前端”系列著作出版发行

杰哥著作《VueSpringBoot前后端分离开发实战》2021年出版以来&#xff0c;累计发行2.6万册&#xff0c;受到广大读者热捧。后应读者要求&#xff0c;受出版社再次邀请&#xff0c;“大前端”系列之《剑指大前端全栈工程师》、《前端三剑客》由清华大学出版社陆续出版发行。系列…

Django日志(二)

一、Handler Handler决定如何处理logger中的每条消息。它表示一个特定的日志行为,例如 将消息写入屏幕、文件或网络Socket handler对应的是个字典,每一个键都是一个handler的名字,每个值又一个字典,描述了如何配置对应的handler实例 2.1、内置Handler class(必需):处理…

STM32最小核心板使用HAL库ADC读取MCU温度(使用DMA通道)

STM32自带CPU的温度数据&#xff0c;需要使用ADC去读取。因此在MX创建项目时如图配置&#xff1a; 模块初始化代码如下&#xff1a; void MX_ADC1_Init(void) {/* USER CODE BEGIN ADC1_Init 0 *//* USER CODE END ADC1_Init 0 */ADC_ChannelConfTypeDef sConfig {0};/* USER…

敢为天下先!深圳市全力推动鸿蒙生态发展……程序员

3月19日&#xff0c;鸿蒙生态创新中心揭幕仪式在深圳正式举行。鸿蒙生态创新中心的建立是为构建先进完整、自主研发的鸿蒙生态体系&#xff0c;将深圳打造为鸿蒙生态策源地、集聚区的具体举措&#xff0c;也是推动我国关键核心技术高水平自立自强、数字经济高质量发展、保障国家…

开源的OCR工具基本使用:PaddleOCR/Tesseract/CnOCR

前言 因项目需要&#xff0c;调研了一下目前市面上一些开源的OCR工具&#xff0c;支持本地部署&#xff0c;非调用API&#xff0c;主要有PaddleOCR/CnOCR/chinese_lite OCR/EasyOCR/Tesseract/chineseocr/mmocr这几款产品。 本文主要尝试了EasyOCR/CnOCR/Tesseract/PaddleOCR这…

基于Springboot+Vue的在线考试系统

项目介绍 这是一个在线考试系统&#xff0c;使用Maven进行项目管理&#xff0c;基于springbootmybatis框架开发的项目&#xff0c;mysql底层数据库&#xff0c;前端采用VueElementPlus&#xff0c;作为初学springbootvue前后端分离架构的同学是一个很不错的项目&#xff0c;如…

软件工程-第5章 结构化设计

5.1 总体设计的目标及其表示方法 5.2 总体设计 变换设计基本步骤&#xff1a; 第1步&#xff1a;设计准备--复审并精华系统模型&#xff1b; 第2步&#xff1a;确定输入、变换、输出这三部分之间的边界&#xff1b; 第3步&#xff1a;第一级分解--系统模块结构图顶层和第一层…

大模型来了,你的“存力”攒够了吗?

作者 | 曾响铃 文 | 响铃说 提到AI、大模型&#xff0c;很多人脑海里最先想到的是算力、算法、数据这“三驾马车”。 而要论谁最重要&#xff0c;恐怕多数人都会觉得是算力。 毕竟&#xff0c;“算力紧缺”的气氛常常被渲染起来。 然而&#xff0c;随着大模型进一步演进&a…

MySQL 字段定义时的属性设置

开发的时候第一步就是建表&#xff0c;在创建表的时候&#xff0c;我们需要定义表的字段&#xff0c;每个字段都有一些属性&#xff0c;比如说是否为空&#xff0c;是否允许有默认值&#xff0c;是不是逐渐等。 这些约束字段的属性&#xff0c;可以让字段的值更符合我们的预期&…

什么是代理IP?TikTok运营需要知道的IP知识

对于运营TikTok的从业者来说&#xff0c;IP的重要性自然不言而喻。 在其他条件都正常的情况下&#xff0c;拥有一个稳定&#xff0c;纯净的IP&#xff0c;你的视频起始播放量很可能比别人高出不少&#xff0c;而劣质的IP轻则会限流&#xff0c;重则会封号。那么&#xff0c;如何…

ThreaTrace复现记录

1. 环境配置 服务器环境 需要10.2的cuda版本 conda环境 包的版本&#xff1a; python 3.6.13 pytorch 1.9.1 torch-cluster 1.5.9 torch-scatter 2.0.9 torch-sparse 0.6.12 torch-spline-conv 1.2.1 torch-geometric 1.4.3 环境bug 这里环境搭建好以后&#xff0c;就可以正…

有哪些工具可以替代Gitbook?这篇文章告诉你

你是否曾经在搜索在线文档创建和共享工具时&#xff0c;遇到了Gitbook? Gitbook 是一个相当出色的工具&#xff0c;具有强大的编辑和发布功能&#xff0c;但也有其不足之处&#xff0c;如使用起来有一定的技术要求&#xff0c;入门门槛较高等。如果你正在寻找Gitbook的替代品&…