Linux系统编程:自定义协议(序列化和反序列化)

1. 协议

        在之前我们谈到,协议就是一种"约定",socket api接口,在读写数据时,都是按照"字符串"的方式来发送接收的,那么我们要传输一些"结构化"数据时怎么办呢?,比如说一个结构体 eg:

struct message{string url;string time;string id;string msg;
};

我们可以将数据,变为一个字符串(有效载荷),并为其添加报头(包含数据的一些属性),最后形成一个报文,这个过程就是序列化的过程 再将这个报文发送到网络中;另一台主机从网络中接收到该数据,将其取报头,并且将字符串转换为我们上面的结构化数据,这个过程就是反序列化。

业务结构化数据在发送到玩过中时先序列化再发送,收到的数据一定是序列字节流,要先进行反序列化,然后才能使用。

2. 自定义协议

下面我们实现一个网络版本的服务端和客户端,你并且自定义一个协议,实现序列化反序列化的过程。

makefile文件:

cc=g++
LD=-DMYSELF
.PHONY:all
all:calServer calClientcalServer:calServer.cc$(cc) -o $@ $^ -std=c++11 -ljsoncpp ${LD}
calClient:calClient.cc$(cc) -o $@ $^ -std=c++11 -ljsoncpp ${LD}.PHONY:clean
clean:rm -f calClient calServer

log.hpp(日志):

#pragma once#include <iostream>
#include <cstring>
#include <string>
#include <stdarg.h>
#include <ctime>
#include <unistd.h>using namespace std;#define DEBUG   0
#define NORMAL  1
#define WARNING 2
#define ERROR   3  // 出错可运行
#define FATAL   4  // 致命错误const char* to_levelStr(int level)
{switch ((level)){case DEBUG: return "DEBUG";case NORMAL: return "NORMAL";case WARNING: return "WARNING";case ERROR: return "ERROR";case FATAL: return "FATAL";default: return nullptr;}
}void logMessage(int level, const char* format, ...) // ... 可变参数列表 
{
#define NUM 1024char logPreFix[NUM];snprintf(logPreFix, sizeof(logPreFix), "[%s][%ld][pid: %d]", to_levelStr(level), (long int)time(nullptr), getpid());char logContent[NUM];va_list arg;va_start(arg, format);vsnprintf(logContent, sizeof(logContent), format, arg);cout << logPreFix << logContent << endl;}

 protocol.hpp:

在这个文件中,定义了请求和响应类,并在类中实现了请求和响应的序列化(serialize)和反序列化(deserialize)操作;并且实现了添加报头(enLength)和去报头的操作(deLength)

#pragma once#include <iostream>
#include <cstring>
#include <string>
#include <vector>
#include <sys/socket.h>
#include <sys/types.h>
#include <jsoncpp/json/json.h>#define SEP " "
#define SEP_LEN strlen(SEP)  // 不能使用sizeof()
#define LINE_SEP "\r\n"
#define LINE_SEP_LEN strlen(LINE_SEP)enum { OK = 0, DIV_ZERO, MOD_ZERO, OP_ERROR };  // "_exitcode _result" -> "content_len"\r\n"_exitcode _result"\r\n
// "_x _op _y\r\n" -> "content_len"\r\n"__x _op _y"\r\n
std::string enLength(const std::string& text)
{std::string send_string = std::to_string(text.size());send_string += LINE_SEP;send_string += text; send_string += LINE_SEP;return send_string;
}// "content_len"\r\n"_x _op _y"\r\n -> "_x _op _y"
bool deLength(const std::string& package, std::string* text)
{auto pos = package.find(LINE_SEP);if(pos == std::string::npos) return false;std::string text_len_string = package.substr(0, pos);int text_len = std::stoi(text_len_string);*text = package.substr(pos+LINE_SEP_LEN, text_len);return true;
}// 没有人规定我们的网络通信的时候 只能有一种协议
// 我们如何让系统知道我们用的哪一种协议?
// "协议编号"\r\n"content_len"\r\n"_exitcode _result"\r\nclass Request
{
public:Request():_x(0),_y(0),_op(0){}Request(int x, int y, char op):_x(x),_y(y),_op(op){}// 序列化 自己写、现成的bool serialize(std::string* out){*out = "";// 结构化 -> "_x _op _y\r\n" 一个请求就一行std::string x_string = std::to_string(_x);std::string y_string = std::to_string(_y);*out = x_string;*out += SEP;*out += _op;*out += SEP;*out += y_string;return true;}// 反序列化bool deserialize(const std::string& in){// "_x _op _y\r\n" -> 结构化数据auto left = in.find(SEP);auto right = in.rfind(SEP); // 从右往前if(left == std::string::npos || right == std::string::npos) return false;if(left == right) return false;std::string x_string = in.substr(0, left); // 前闭后开区间std::string y_string = in.substr(right+SEP_LEN, strlen(in.c_str())-LINE_SEP_LEN); // 前闭后开区间if( right-(left+SEP_LEN) != 1) return false;_op = in[left+SEP_LEN];      if(x_string.empty()) return false;if(y_string.empty()) return false;_x = std::stoi(x_string);_y = std::stoi(y_string);return true;}public:// "_x _op _y" 约定int _x;int _y;char _op;
};class Response
{
public:Response():_exitcode(0),_result(0){}// 序列化bool serialize(std::string* out){*out = "";std::string ec_string = std::to_string(_exitcode);std::string res_string = std::to_string(_result);*out += ec_string;*out += SEP;*out += res_string;return true;}// 反序列化bool deserialize(const std::string& in){// "_exitcode result"auto mid = in.find(SEP);if(mid == std::string::npos) return false;std::string ec_string = in.substr(0, mid);std::string res_string = in.substr(mid+SEP_LEN);if(ec_string.empty() || res_string.empty())return false;_exitcode = std::stoi(ec_string);_result = std::stoi(res_string);}public:int _exitcode; // 0计算成功 !0表示计算失败,具体是多少,定好标准int _result;   // 计算结果
};bool recvPackge(int sock, std::string &inbuffer, std::string *text)
{text->clear();char buffer[1024];while(true){ssize_t n = recv(sock, buffer, sizeof(buffer)-1, 0);if(n > 0){buffer[n] = 0;inbuffer += buffer;// 分析处理auto pos = inbuffer.find(LINE_SEP);if(pos == std::string::npos) continue;std::string text_len_string = inbuffer.substr(0, pos);int text_len = std::stoi(text_len_string); // 得到有效载荷的长度int total_len = text_len_string.size() + 2*LINE_SEP_LEN + text_len;if(total_len > inbuffer.size()) // 缓冲区还没有读到一个完整的报文{std::cout << "你输入的消息没有严格按照我们的协议, 正在等待后续的内容, continue!" << std::endl;continue;}std::cout << "处理前#inbuffer: \n" << inbuffer << std::endl;// 至少有一个完整的报文*text = inbuffer.substr(0, total_len);   // "content_len"\r\n"_exitcode _result"\r\ninbuffer.erase(0, total_len);std::cout << "处理后#inbuffer: \n" << inbuffer << std::endl;break;}else return false;}return true;
}

server.hpp:

服务端,在接收到来自客户端的请求后,将其进行反序列化、去报头得到数据,在对数据进行计算,得到结果后,加报头序列化,组成一个响应发送给客户端。

#pragma once#include "log.hpp"
#include "protocol.hpp"#include <iostream>
#include <string>
#include <cstring>
#include <functional>
#include <cstdlib>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <signal.h>namespace server
{using namespace std;enum{USAGE_ERR = 1,SOCKET_ERR,BIND_ERR,LISTEN_ERR};static const uint16_t gport = 8080;static const int gbacklog = 5;typedef std::function<bool(const Request &req, Response &resp)> func_t;// 解耦void handlerEntery(int sock, func_t func){std::string inbuffer;while (true){// 1.读取,content_len"\r\n"_exitcode _result"\r\n// 1.1 如何保证独到的消息是 一个!完整的请求呢?std::string req_text, req_str;// 1.2 读取成功,req_text是一个完整的请求:content_len"\r\n"_exitcode _result"\r\nif (!recvPackge(sock, inbuffer, &req_text))return; // 读取失败std::cout << "带报头的请求: \n" << req_text << std::endl;if (!deLength(req_text, &req_str))return;std::cout << "去报头的正文: \n" << req_str << std::endl;// 2.对请求request反序列化// 2.1 得到一个结构化的请求对象Request req;if (!req.deserialize(req_str))return;// 3.计算处理业务 req._x, req._op, req._y --- 业务逻辑// 3.1 得到一个结构化的响应Response resp;func(req, resp); // 调用的为.cc中的cal// 4.对相应Response进行序列化// 4.1 得到了一个序列化的数据std::string resp_str;resp.serialize(&resp_str);std::cout << "计算完成,序列化响应:" << req_str << std::endl;// 5.然后发送响应给客户端// 5.1构建成为一个完整的报文std::string send_string = enLength(resp_str);std::cout << "构建完整的响应:\n" << send_string << std::endl;send(sock, send_string.c_str(), send_string.size(), 0); // 这里的发送也是有问题的}}class CalServer{public:CalServer(const uint16_t &port = gport): _port(port), _listenSockfd(-1){}void initServer(){// 1.创建socket文件套接字对象_listenSockfd = socket(AF_INET, SOCK_STREAM, 0); // 第二个参数与UDP不同if (_listenSockfd < 0){// 创建套接字失败logMessage(FATAL, "created socket error!");exit(SOCKET_ERR);}logMessage(NORMAL, "created socket success: %d!", _listenSockfd);// 2.bind绑定自己的网络信息struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;if (bind(_listenSockfd, (struct sockaddr *)&local, sizeof(local)) < 0){logMessage(FATAL, "bind socket error!");exit(BIND_ERR);}logMessage(NORMAL, "bind socket success!");// 3.设置socket 为监听状态if (listen(_listenSockfd, gbacklog) < 0) // 第二个参数backlog后面会讲 5的倍数{logMessage(FATAL, "listen socket error!");exit(LISTEN_ERR);}logMessage(NORMAL, "listen socket success!");}void start(func_t func){for (;;){// 4.server 获取新连接 不能直接接收数据/发送数据struct sockaddr_in peer;socklen_t len = sizeof(peer);int sock = accept(_listenSockfd, (struct sockaddr *)&peer, &len); // sock 和client进行通信if (sock < 0){logMessage(ERROR, "accept error, next!");continue;}logMessage(NORMAL, "accept a new link success, get new sock: %d!", sock); // ?// version 2, 多进程版,pid_t id = fork();if (id == 0){// 子进程 向外提供服务 不需要监听 关闭这个文件描述符close(_listenSockfd);// if(fork() > 0) exit(0); // 让子进程的子进程执行下面代码 子进程退出// serviceIO(sock);handlerEntery(sock, func); // 读取请求close(sock);               // 关闭父进程的exit(0);                   // 最后变成孤儿进程 交给OS回收这个进程}close(sock); // 关闭子进程的// 父进程pid_t ret = waitpid(id, nullptr, 0); // 阻塞式等待if (ret > 0){logMessage(NORMAL, "wait child process seccess");}}}~CalServer(){}private:int _listenSockfd; // 套接字 -- 不是用来通信的 是用来监听链接到来,获取新链接的!uint16_t _port;    // 端口号};
}

server.cc:

# include "calServer.hpp"
# include "protocol.hpp"
#include <memory>using namespace server;
using namespace std;static void Usage(string proc)
{cout << "Usage:\n\t" << proc << " local_ip local_port\n\n";
}// req一定是我们的处理好的完整的请求对象
// resp:根据req进行业务处理,填充resp,不用管任何读取和写入,序列化和反序列化等任何细节
bool cal(const Request& req, Response& resp)
{// req已经有结构化完成的数据了 可以直接使用resp._exitcode = 0;resp._result = OK;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._exitcode = DIV_ZERO; // else resp._result = req._x / req._y;}break;case '%':{   if(req._y == 0) resp._exitcode = MOD_ZERO; // else resp._result = req._x % req._y;}break;default:resp._exitcode = OP_ERROR;break;}return true;
}// tcp服务器,在启动上与之前的udp server一模一样
// ./tcpServer localport
int main(int argc, char *argv[])
{if(argc != 2){Usage(argv[0]);exit(USAGE_ERR);}uint16_t port = atoi(argv[1]);unique_ptr<CalServer> tsvr(new CalServer(port));tsvr->initServer();tsvr->start(cal);return 0;
}

client.hpp:

客户端从键盘输入要计算的数据和运算符和,将其加包头、序列化组成一个请求字符串,发送给服务端,然后阻塞等待,直到接收到服务端的响应后,将其反序列化去报头就是得到的计算结果打印到屏幕上。

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <ctype.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>#include "protocol.hpp"using namespace std;#define NUM 1024class CalClient
{
public:CalClient(const string& serverIp, const uint16_t& serverPort):_sock(-1),_serverIp(serverIp),_serverPort(serverPort){}void initClient(){// 1.创建socket_sock = socket(AF_INET, SOCK_STREAM, 0);if(_sock < 0){cerr << "socket creat error!" << endl;exit(2);}// 2.TCP的客户端要bind 但不需要显式的bind,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){cerr << "socket connect error" << endl;}else{string line;std::string inbuffer;while(true){cout << "Mycal>>> ";getline(cin, line);Request req = ParseLine(line);std::string content;req.serialize(&content); // 序列化std::string send_string = enLength(content);send(_sock, send_string.c_str(), send_string.size(), 0); // BUG?std::string package, text;if(!recvPackge(_sock, inbuffer, &package)) continue;if(!deLength(package, &text)) continue;Response resp;resp.deserialize(text);std::cout << "exitCode: " << resp._exitcode << std::endl;std::cout << "result: " << resp._result << std::endl;}}}const Request& ParseLine(const std::string& line){// "1+1" "123*123" "21/0"int status = 0; // 0:开始 1:碰到操作符 2:操作符之后int i = 0;int cnt = line.size();std::string left, right; // 左右操作数char op;while(i < cnt){switch(status){case 0:{if(!isdigit(line[i])) {op = line[i];status = 1;}else left.push_back(line[i++]);}break;  case 1:i++;status = 2;break;case 2:right.push_back(line[i++]); break;}}cout << std::stoi(left) << op << std::stoi(right) << std::endl;return Request(std::stoi(left), std::stoi(right), op);}~CalClient(){if(_sock >= 0) close(_sock);}private:int _sock;string _serverIp;uint16_t _serverPort;
};

 client.cc:

# include "calClient.hpp"#include <memory>using namespace std;static void Usage(string proc)
{cout << "Usage:\n\t" << proc << " server_ip server_port\n\n";
}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<CalClient> tcli(new CalClient(serverIp, serverPort));tcli->initClient();tcli->start();return 0;
}

上面的代码我们实现了一个简单的网络计算器代码,并且在protocol.hpp文件中实现了自定义协议,自己实现了服务端客户端响应与请求的序列化反序列化的操作,下面为一个测试用例的代码:

客户端和服务端还分别打印出了报文的形状。

在tcp中,客户端和服务端发送的本质都是将数据从自定义的缓冲区中拷贝到他们的发送缓冲区,在由OS决定在合适的时间发送到网络中。接受的本质就是从网络中拷贝数据到接收缓冲区,再拷贝到自定义的字符串或者变量中。发送缓冲区和接收缓冲区都是独立的。

TCP是如何保证收到一个完整的报文的? -- tcp是面向字节流的,所以可以明确报文和报文的边界:定长、特殊符号、子描述方式。在我们上面的代码中,采取的是特殊符号来确定报文的边界。

在这里再介绍两个接口:

# include <sys/types.h>

# include <sys/socket.h>

ssize_t send(int sockfd, const void* buff, size_t len, int flags);

ssize_t recv(int sockfd, void* buff, size_t len, int flags);

  1. send 和 sendto

    • send: 用于在已连接的TCP套接字上发送数据。在使用send时,操作系统知道要发送数据的套接字,并且已经建立了与远程主机的连接。因此,send不需要指定目标地址,因为操作系统已经知道数据将被发送到哪里。
    • sendto: 用于无连接的UDP套接字,也可以用于TCP套接字。在使用sendto时,需要指定目标地址和端口号,因为它没有依赖于之前的连接。在TCP中,尽管可以使用sendto发送数据,但通常更常见的是使用send,因为TCP是面向连接的协议,连接已经被建立,操作系统已经知道目标地址。
  2. recv 和 recvfrom

    • recv: 用于从已连接的TCP套接字接收数据。类似于send,recv操作系统知道从哪个套接字接收数据,因为连接已经建立。recv不需要指定源地址,因为操作系统已经知道要从哪里接收数据。
    • recvfrom: 用于无连接的UDP套接字,也可以用于TCP套接字。在使用recvfrom时,需要指定一个缓冲区来存储接收到的数据,以及一个指向结构体的指针,该结构体用于存储发送方的地址和端口号。在TCP中,虽然可以使用recvfrom接收数据,但通常更常见的是使用recv,因为TCP是面向连接的,连接已经建立。

总结来说,send和recv适用于TCP套接字,而sendto和recvfrom主要用于UDP套接字,但它们也可以在TCP套接字上使用。在TCP中,通常使用send和recv,因为连接已经建立,操作系统已经知道目标地址和源地址。

对于序列化和反序列化,有现成的方案可以使用:1.json、2.protobud、3.xml 

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

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

相关文章

前端-04-VScode敲击键盘有键入音效,怎么关闭

目录 问题解决办法 问题 今天正在VScode敲项目&#xff0c;不知道是按了什么快捷键还是什么的&#xff0c;敲击键盘有声音&#xff0c;超级烦人啊&#xff01;&#xff01;于是我上网查了一下&#xff0c;应该是开启了VScode的键入音效&#xff0c;下面是关闭键入音效的办法。…

kafka---消息日志详解

一、Log Flush Policy&#xff08;log flush 策略&#xff09; 1、设置内存中保留日志的个数&#xff0c;当达到这个数量的时候&#xff0c;内存中的数据会被强制刷到disk中 log.flush.interval.messages10000 2、设置内存中保留日志的时间&#xff0c;当达到这个时间的时候&am…

DP刷题(1500-1700)

1.区间DP&#xff1a;https://www.acwing.com/problem/content/323/ 比较容易想到区间DP,转换一下均方差定义用记忆化搜索就可以了。 下面是AC代码&#xff1a; #include<bits/stdc.h> using namespace std; const int N 16; int n, m 8; int s[N][N]; double f[N][…

现在进行时的被动语态:为什么是 “being“?

在学习英语语法时&#xff0c;曾对现在进行时的被动语态感到困惑&#xff0c;特别是为什么要用“being”这个词。 1. 进行时态&#xff08;Present Continuous Tense&#xff09; 进行时态用于表示动作正在发生。其结构是&#xff1a;主语 am/is/are 动词的现在分词&#xf…

分布式服务框架zookeeper+消息队列kafka

一、zookeeper概述 zookeeper是一个分布式服务框架&#xff0c;它主要是用来解决分布式应用中经常遇到的一些数据管理问题&#xff0c;如&#xff1a;命名服务&#xff0c;状态同步&#xff0c;配置中心&#xff0c;集群管理等。 在分布式环境下&#xff0c;经常需要对应用/服…

云计算数据中心(三)

目录 四、自动化管理&#xff08;一&#xff09;自动化管理的特征&#xff08;二&#xff09;自动化管理实现阶段&#xff08;三&#xff09;Facebook自动化管理 五、容灾备份&#xff08;一&#xff09;容灾系统的等级标准&#xff08;二&#xff09;容灾备份的关键技术&#…

Oracle19.24发布,打补丁到19.24

一. 19.24发布 2024年7月16日 19c&#xff0c;19.24补丁发布 文档编号19202407.9&#xff0c;文档编码规则&#xff1a; 19&#xff08;版本号&#xff09;2024&#xff08;年份&#xff09;07&#xff08;当季的第一个月01/04/07/10&#xff09;.9 一般每个季度的首月中16…

02-Spring Core中的设计模式分析

Spring Core中的设计模式分析 1. 单例模式 (Singleton Pattern) 源码分析&#xff1a; 在Spring框架中&#xff0c;Bean默认是单例的。这意味着在整个Spring IoC容器中&#xff0c;只有一个Bean实例。Spring通过DefaultSingletonBeanRegistry来实现单例模式。 public class…

Android Launcher3桌面图标样式修改(添加圆角)

1.源码类&#xff1a;LauncherActivityCachingLogic.java /** Copyright (C) 2018 The Android Open Source Project** Licensed under the Apache License, Version 2.0 (the "License");* you may not use this file except in compliance with the License.* You…

js修改hash的方法

关键&#xff1a; window.onhashchange (event) > {// do something }hash变化包括 js修改hash手动修改url的hash浏览器前进、后退 js修改hash: location.href "#user";在vue-router等路由组件中如何实现history模式呢&#xff1f; 关键函数&#xff1a;hi…

【学习笔记】Redis学习笔记——第14章 客户端

第14章 服务器 14.1 命令请求的执行过程 14.1.1 发送命令请求 客户端将发送的命令准换成协议格式然后发送给服务器 14.1.2 读取命令请求 1>保存命令至客户端状态输入缓冲区 2>提取命令参数及参数个数保存至客户端状态的argv与argc字段中 3>获取命令执行器并执行命…

Flink CDC 同步表至Paimon 写数据流程,write算子和commit算子。

Flink CDC 同步表至Paimon 写数据流程,write算子和commit算子。(未吃透版) 流程图 一般基本flink cdc 任务同步数据至paimon表时包含3个算子,source、write、global commit。 source端一般是flink connector实现的连接源端进行获取数据的过程,本文探究的是 source算子获…

Haproxy服务

目录 一.haproxy介绍 1.主要特点和功能 2.haproxy 调度算法 3.haproxy 与nginx 和lvs的区别 二.安装 haproxy 服务 1. yum安装 2.第三方rpm 安装 3.编译安装haproxy 三.配置文件详解 1.官方地址配置文件官方帮助文档 2.HAProxy 的配置文件haproxy.cfg由两大部分组成&…

Synchronized升级到重量级锁会发生什么?

我们从网上看到很多&#xff0c;升级到重量锁的时候不会降级&#xff0c;再来线程都是重量级锁 今天我们来实验一把真的是这样的吗 1.首选导入Java对象内存布局的工具库&#xff1a; <dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-…

【moyu】河北省职工职业技能大赛决赛

[32m [33mMOYU [32m[0m 工作不算争取价值&#xff0c;是劳动换取酬劳&#xff1b; 工作的时候偷闲才是为自己争取价值。 [32m[0m****************************************************** ******************* 让我摸个鱼吧&#xff01; ******************* *****************…

二叉树---最大二叉树

题目&#xff1a; 给定一个不重复的整数数组 nums 。 最大二叉树 可以用下面的算法从 nums 递归地构建: 创建一个根节点&#xff0c;其值为 nums 中的最大值。递归地在最大值 左边 的 子数组前缀上 构建左子树。递归地在最大值 右边 的 子数组后缀上 构建右子树。 返回 nums…

web前端 React 框架面试200题(四)

面试题 97. React 两种路由模式的区别&#xff1f;hash和history&#xff1f; 参考回答&#xff1a; 1: hash路由 hash模式是通过改变锚点(#)来更新页面URL&#xff0c;并不会触发页面重新加载&#xff0c;我们可以通过window.onhashchange监听到hash的改变&#xff0c;从而处…

什么是内网穿透?

前言 我们常常会听到“内网穿透”这个术语&#xff0c;但对于很多人来说&#xff0c;它可能还比较陌生。作为一个在网络世界中摸索了一段时间的使用者&#xff0c;我来和大家分享一下我对内网穿透的理解。 目录 一、内网穿透介绍 二、发现 三、特点 四、优势 简单来说&am…

初识godot游戏引擎并安装

简介 Godot是一款自由开源、由社区驱动的2D和3D游戏引擎。游戏开发虽复杂&#xff0c;却蕴含一定的通用规律&#xff0c;正是为了简化这些通用化的工作&#xff0c;游戏引擎应运而生。Godot引擎作为一款功能丰富的跨平台游戏引擎&#xff0c;通过统一的界面支持创建2D和3D游戏。…

web前端 React 框架面试200题(五)

面试题 129. React.forwardRef是什么&#xff1f;它有什么作用&#xff1f; 参考回答&#xff1a; React.forwardRef 会创建一个React组件&#xff0c;这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。这种技术并不常见&#xff0c;但在以下两种场景中特别有…