本文由云+社区发表javascript
做者:韩伟html
大概已经有差很少一年没写技术文章了,缘由是今年投入了一些具体游戏项目的开发。这些新的游戏项目,比较接近独立游戏的开发方式。我以为公司的“祖传”服务器框架技术不太适合,因此从头写了一个游戏服务器端的框架,以便得到更好的开发效率和灵活性。如今项目将近上线,有时间就想总结一下,这样一个游戏服务器框架的设计和实现过程。html5
这个框架的基本运行环境是 Linux ,采用 C++ 编写。为了能在各类环境上运行和使用,因此采用了 gcc 4.8 这个“古老”的编译器,以 C99 规范开发。java
因为“越通用的代码,就是越没用的代码”,因此在设计之初,我就认为应该使用分层的模式来构建整个系统。按照游戏服务器的通常需求划分,最基本的能够分为两层:redis
我但愿能有一个基本完整的“底层基础功能”的框架,能够被复用于多个不一样的游戏。因为目标是开发一个 适合独立游戏开发 的游戏服务器框架。因此最基本的需求分析为:数据库
功能性需求编程
非功能性需求json
一旦需求明确下来,基本的层级结构也能够设计了:api
层次 | 功能 | 约束 |
---|---|---|
逻辑层 | 实现更具体的业务逻辑 | 能调用全部下层代码,但应主要依赖接口层 |
实现层 | 对各类具体的通讯协议、存储设备等功能的实现 | 知足下层的接口层来作实现,禁止同层间互相调用 |
接口层 | 定义了各模块的基本使用方式,用以隔离具体的实现和设计,从而提供互相替换的能力 | 本层之间代码能够互相调用,但禁止调用上层代码 |
工具层 | 提供通用的 C++ 工具库功能,如 log/json/ini/日期时间/字符串处理 等等 | 不该该调用其余层代码,也不该该调用同层其余模块 |
第三方库 | 提供诸如 redis/tcaplus 或者其余现成功能,其地位和“工具层”同样 | 不该该调用其余层代码,甚至不该该修改其源码 |
最后,总体的架构模块相似:数组
说明 | 通讯 | 处理器 | 缓存 | 持久化 |
---|---|---|---|---|
功能实现 | TcpUdpKcpTlvLine | JsonHandlerObjectProcessor | SessionLocalCacheRedisMapRamMapZooKeeperMap | FileDataStoreRedisDataStroe |
接口定义 | TransferProtocol | ServerClientProcessor | DataMapSerializable | DataStore |
工具类库 | ConfigLOGJSONCoroutine |
对于通讯模块来讲,须要有灵活的可替换协议的能力,就必须按必定的层次进行进一步的划分。对于游戏来讲,最底层的通讯协议,通常会使用 TCP 和 UDP 这两种,在服务器之间,也会使用消息队列中间件一类通讯软件。框架必需要有能同事支持这几通讯协议的能力。故此设计了一个层次为: Transport
在协议层面,最基本的需求有“分包”“分发”“对象序列化”等几种需求。若是要支持“请求-响应”模式,还须要在协议中带上“序列号”的数据,以便对应“请求”和“响应”。另外,游戏一般都是一种“会话”式的应用,也就是一系列的请求,会被视为一次“会话”,这就须要协众须要有相似 Session ID
这种数据。为了知足这些需求,设计一个层次为: Protocol
拥有了以上两个层次,是能够完成最基本的协议层能力了。可是,咱们每每但愿业务数据的协议包,能自动化的成为编程中的 对象,因此在处理消息体这里,须要一个可选的额外层次,用来把字节数组,转换成对象。因此我设计了一个特别的处理器:ObjectProcessor ,去规范通讯模块中对象序列化、反序列化的接口。
输入 | 层次 | 功能 | 输出 |
---|---|---|---|
data | Transport | 通讯 | buffer |
buffer | Protocol | 分包 | Message |
Message | Processor | 分发 | object |
object | 处理模块 | 处理 | 业务逻辑 |
此层次是为了统一各类不一样的底层传输协议而设置的,最基本应该支持 TCP 和 UDP 这两种协议。对于通讯协议的抽象,其实在不少底层库也作的很是好了,好比 Linux 的 socket 库,其读写 API 甚至能够和文件的读写通用。C# 的 Socket 库在 TCP 和 UDP 之间,其 api 也几乎是彻底同样的。可是因为做用游戏服务器,不少适合还会接入一些特别的“接入层”,好比一些代理服务器,或者一些消息中间件,这些 API 但是五花八门的。另外,在 html5 游戏(好比微信小游戏)和一些页游领域,还有用 HTTP 服务器做为游戏服务器的传统(如使用 WebSocket 协议),这样就须要一个彻底不一样的传输层了。
服务器传输层在异步模型下的基本使用序列,就是:
根据上面三个特色,能够概括出一个基本的接口:
class Transport {
public:
/** * 初始化Transport对象,输入Config对象配置最大链接数等参数,能够是一个新建的Config对象。 */
virtual int Init(Config* config) = 0;
/** * 检查是否有数据能够读取,返回可读的事件数。后续代码应该根据此返回值循环调用Read()提取数据。 * 参数fds用于返回出现事件的全部fd列表,len表示这个列表的最大长度。若是可用事件大于这个数字,并不影响后续能够Read()的次数。 * fds的内容,若是出现负数,表示有一个新的终端等待接入。 */
virtual int Peek(int* fds, int len) = 0;
/** * 读取网络管道中的数据。数据放在输出参数 peer 的缓冲区中。 * @param peer 参数是产生事件的通讯对端对象。 * @return 返回值为可读数据的长度,若是是 0 表示没有数据能够读,返回 -1 表示链接须要被关闭。 */
virtual int Read( Peer* peer) = 0;
/** * 写入数据,output_buf, buf_len为想要写入的数据缓冲区,output_peer为目标队端, * 返回值表示成功写入了的数据长度。-1表示写入出错。 */
virtual int Write(const char* output_buf, int buf_len, const Peer& output_peer) = 0;
/** * 关闭一个对端的链接 */
virtual void ClosePeer(const Peer& peer) = 0;
/** * 关闭Transport对象。 */
virtual void Close() = 0;
}
复制代码
在上面的定义中,能够看到须要有一个 Peer 类型。这个类型是为了表明通讯的客户端(对端)对象。在通常的 Linux 系统中,通常咱们用 fd (File Description)来表明。可是由于在框架中,咱们还须要为每一个客户端创建接收数据的缓存区,以及记录通讯地址等功能,因此在 fd 的基础上封装了一个这样的类型。这样也有利于把 UDP 通讯以不一样客户端的模型,进行封装。
///@brief 此类型负责存放链接过来的客户端信息和数据缓冲区
class Peer {
public:
int buf_size_; ///< 缓冲区长度
char* const buffer_;///< 缓冲区起始地址
int produced_pos_; ///< 填入了数据的长度
int consumed_pos_; ///< 消耗了数据的长度
int GetFd() const;
void SetFd(int fd); /// 得到本地地址
const struct sockaddr_in& GetLocalAddr() const;
void SetLocalAddr(const struct sockaddr_in& localAddr); /// 得到远程地址
const struct sockaddr_in& GetRemoteAddr() const;
void SetRemoteAddr(const struct sockaddr_in& remoteAddr);
private:
int fd_; ///< 收发数据用的fd
struct sockaddr_in remote_addr_; ///< 对端地址
struct sockaddr_in local_addr_; ///< 本端地址
};
复制代码
游戏使用 UDP 协议的特色:通常来讲 UDP 是无链接的,可是对于游戏来讲,是确定须要有明确的客户端的,因此就不能简单用一个 UDP socket 的fd 来表明客户端,这就形成了上层的代码没法简单在 UDP 和 TCP 之间保持一致。所以这里使用 Peer 这个抽象层,正好能够接近这个问题。这也能够用于那些使用某种消息队列中间件的状况,由于可能这些中间件,也是多路复用一个 fd 的,甚至可能就不是经过使用 fd 的 API 来开发的。
对于上面的 Transport 定义,对于 TCP 的实现者来讲,是很是容易能完成的。可是对于 UDP 的实现者来讲,则须要考虑如何宠妃利用 Peer ,特别是 Peer.fd_ 这个数据。我在实现的时候,使用了一套虚拟的 fd 机制,经过一个客户端的 IPv4 地址到 int 的对应 Map ,来对上层提供区分客户端的功能。在 Linux 上,这些 IO 均可以使用 epoll 库来实现,在 Peek() 函数中读取 IO 事件,在 Read()/Write() 填上 socket 的调用就能够了。
另外,为了实现服务器之间的通讯,还须要设计和 Tansport 对应的一个类型:Connector 。这个抽象基类,用于以客户端模型对服务器发起请求。其设计和 Transport 大同小异。除了 Linux 环境下的 Connecotr ,我还实现了在 C# 下的代码,以便用 Unity 开发的客户端能够方便的使用。因为 .NET 自己就支持异步模型,因此其实现也不费太多功夫。
/** * @brief 客户端使用的链接器类,表明传输协议,如 TCP 或 UDP */
class Connector {
public: virtual ~Connector() {}
/** * @brief 初始化创建链接等 * @param config 须要的配置 * @return 0 为成功 */
virtual int Init(Config* config) = 0;
/** * @brief 关闭 */
virtual void Close() = 0;
/** * @brief 读取是否有网络数据到来 * 读取有无数据到来,返回值为可读事件的数量,一般为1 * 若是为0表示没有数据能够读取。 * 若是返回 -1 表示出现网络错误,须要关闭此链接。 * 若是返回 -2 表示此链接成功连上对端。 * @return 网络数据的状况 */
virtual int Peek() = 0;
/** * @brief 读取网络数 * 读取链接里面的数据,返回读取到的字节数,若是返回0表示没有数据, * 若是buffer_length是0, 也会返回0, * @return 返回-1表示链接须要关闭(各类出错也返回0) */
virtual int Read(char* ouput_buffer, int buffer_length) = 0;
/** * @brief 把input_buffer里的数据写入网络链接,返回写入的字节数。 * @return 若是返回-1表示写入出错,须要关闭此链接。 */
virtual int Write(const char* input_buffer, int buffer_length) = 0;
protected:
Connector(){}
};
复制代码
对于通讯“协议”来讲,其实包含了许许多多的含义。在众多的需求中,我所定义的这个协议层,只但愿完成四个最基本的能力:
除了以上三个功能,实际上但愿在协议层处理的能力,还有不少,最典型的就是对象序列化的功能,还有压缩、加密功能等等。我之因此没有把对象序列化的能力放在 Protocol 中,缘由是对象序列化中的“对象”自己是一个业务逻辑关联性很是强的概念。在 C++ 中,并无完整的“对象”模型,也缺少原生的反射支持,因此没法很简单的把代码层次经过“对象”这个抽象概念划分开来。可是我也设计了一个 ObjectProcessor ,把对象序列化的支持,以更上层的形式结合到框架中。这个 Processor 是能够自定义对象序列化的方法,这样开发者就能够本身选择任何“编码、解码”的能力,而不须要依靠底层的支持。
至于压缩和加密这一类功能,确实是能够放在 Protocol 层中实现,甚至能够做为一个抽象层次加入 Protocol ,可能只有一个 Protocol 层不足以支持这么丰富的功能,须要好像 Apache Mina 这样,设计一个“调用链”的模型。可是为了简单起见,我以为在具体须要用到的地方,再额外添加 Protocol 的实现类就好,好比添加一个“带压缩功能的 TLV Protocol 类型”之类的。
消息自己被抽象成一个叫 Message 的类型,它拥有“服务名字”“会话ID”两个消息头字段,用以完成“分发”和“会话保持”功能。而消息体则被放在一个字节数组中,并记录下字节数组的长度。
enum MessageType {
TypeError, ///< 错误的协议
TypeRequest, ///< 请求类型,从客户端发往服务器
TypeResponse, ///< 响应类型,服务器收到请求后返回
TypeNotice ///< 通知类型,服务器主动通知客户端
};
///@brief 通讯消息体的基类
///基本上是一个 char[] 缓冲区
struct Message {
public:
static int MAX_MAESSAGE_LENGTH;
static int MAX_HEADER_LENGTH;
MessageType type; ///< 此消息体的类型(MessageType)信息
virtual ~Message(); virtual Message& operator=(const Message& right);
/** * @brief 把数据拷贝进此包体缓冲区 */
void SetData(const char* input_ptr, int input_length);
///@brief 得到数据指针
inline char* GetData() const{
return data_;
}
///@brief 得到数据长度
inline int GetDataLen() const{
return data_len_;
}
char* GetHeader() const;
int GetHeaderLen() const;
protected:
Message();
Message(const Message& message);
private:
char* data_; // 包体内容缓冲区
int data_len_; // 包体长度
};
复制代码
根据以前设计的“请求响应”和“通知”两种通讯模式,须要设计出三种消息类型继承于 Message,他们是:
Request 和 Response 两个类,都有记录序列号的 seq_id 字段,但 Notice 没有。Protocol 类就是负责把一段 buffer 字节数组,转换成 Message 的子类对象。因此须要针对三种 Message 的子类型都实现对应的 Encode() / Decode() 方法。
class Protocol {
public:
virtual ~Protocol() {
}
/** * @brief 把请求消息编码成二进制数据 * 编码,把msg编码到buf里面,返回写入了多长的数据,若是超过了 len,则返回-1表示错误。 * 若是返回 0 ,表示不须要编码,框架会直接从 msg 的缓冲区读取数据发送。 * @param buf 目标数据缓冲区 * @param offset 目标偏移量 * @param len 目标数据长度 * @param msg 输入消息对象 * @return 编码完成所用的字节数,若是 < 0 表示出错 */
virtual int Encode(char* buf, int offset, int len, const Request& msg) = 0;
/** * 编码,把msg编码到buf里面,返回写入了多长的数据,若是超过了 len,则返回-1表示错误。 * 若是返回 0 ,表示不须要编码,框架会直接从 msg 的缓冲区读取数据发送。 * @param buf 目标数据缓冲区 * @param offset 目标偏移量 * @param len 目标数据长度 * @param msg 输入消息对象 * @return 编码完成所用的字节数,若是 < 0 表示出错 */
virtual int Encode(char* buf, int offset, int len, const Response& msg) = 0;
/** * 编码,把msg编码到buf里面,返回写入了多长的数据,若是超过了 len,则返回-1表示错误。 * 若是返回 0 ,表示不须要编码,框架会直接从 msg 的缓冲区读取数据发送。 * @param buf 目标数据缓冲区 * @param offset 目标偏移量 * @param len 目标数据长度 * @param msg 输入消息对象 * @return 编码完成所用的字节数,若是 < 0 表示出错 */
virtual int Encode(char* buf, int offset, int len, const Notice& msg) = 0;
/** * 开始编码,会返回即将解码出来的消息类型,以便使用者构造合适的对象。 * 实际操做是在进行“分包”操做。 * @param buf 输入缓冲区 * @param offset 输入偏移量 * @param len 缓冲区长度 * @param msg_type 输出参数,表示下一个消息的类型,只在返回值 > 0 的状况下有效,不然都是 TypeError * @return 若是返回0表示分包未完成,须要继续分包。若是返回-1表示协议包头解析出错。其余返回值表示这个消息包占用的长度。 */
virtual int DecodeBegin(const char* buf, int offset, int len,
MessageType* msg_type) = 0;
/** * 解码,把以前DecodeBegin()的buf数据解码成具体消息对象。 * @param request 输出参数,解码对象会写入此指针 * @return 返回0表示成功,-1表示失败。 */
virtual int Decode(Request* request) = 0;
/** * 解码,把以前DecodeBegin()的buf数据解码成具体消息对象。 * @param request 输出参数,解码对象会写入此指针 * @return 返回0表示成功,-1表示失败。 */
virtual int Decode(Response* response) = 0;
/** * 解码,把以前DecodeBegin()的buf数据解码成具体消息对象。 * @param request 输出参数,解码对象会写入此指针 * @return 返回0表示成功,-1表示失败。 */
virtual int Decode(Notice* notice) = 0;protected:
Protocol() {
}
};
复制代码
这里有一点须要注意,因为 C++ 没有内存垃圾搜集和反射的能力,在解释数据的时候,并不能一步就把一个 char[] 转换成某个子类对象,而必须分红两步处理。
对于 Protocol 的具体实现子类,我首先实现了一个 LineProtocol ,是一个很是不严谨的,基于文本ASCII编码的,用空格分隔字段,用回车分包的协议。用来测试这个框架是否可行。由于这样能够直接经过 telnet 工具,来测试协议的编解码。而后我按照 TLV (Type Length Value)的方法设计了一个二进制的协议。大概的定义以下:
协议分包: [消息类型:int:2] [消息长度:int:4] [消息内容:bytes:消息长度]
消息类型取值:
包类型 | 字段 | 编码细节 |
---|---|---|
Request | 服务名 | [字段:int:2][长度:int:2][字符串内容:chars:消息长度] |
序列号 | [字段:int:2][整数内容:int:4] | |
会话ID | [字段:int:2][整数内容:int:4] | |
消息体 | [字段:int:2][长度:int:2][字符串内容:chars:消息长度] | |
Response | 服务名 | [字段:int:2][长度:int:2][字符串内容:chars:消息长度] |
序列号 | [字段:int:2][整数内容:int:4] | |
会话ID | [字段:int:2][整数内容:int:4] | |
消息体 | [字段:int:2][长度:int:2][字符串内容:chars:消息长度] | |
Notice | 服务名 | [字段:int:2][长度:int:2][字符串内容:chars:消息长度] |
消息体 | [字段:int:2][长度:int:2][字符串内容:chars:消息长度] |
一个名为 TlvProtocol 的类型完成对这个协议的实现。
处理器层是我设计用来对接具体业务逻辑的抽象层,它主要经过输入参数 Request 和 Peer 来得到客户端的输入数据,而后经过 Server 类的 Reply()/Inform() 来返回 Response 和 Notice 消息。实际上 Transport 和 Protocol 的子类们,都属于 net 模块,而各类 Processor 和 Server/Client 这些功能类型,属于另一个 processor 模块。这样设计的缘由,是但愿全部 processor 模块的代码单向的依赖 net 模块的代码,但反过来不成立。
Processor 基类很是简单,就是一个处理函数回调函数入口 Process()
:
///@brief 处理器基类,提供业务逻辑回调接口
class Processor {
public:
Processor();
virtual ~Processor();
/** * 初始化一个处理器,参数server为业务逻辑提供了基本的能力接口。 */
virtual int Init(Server* server, Config* config = NULL);
/** * 处理请求-响应类型包实现此方法,返回值是0表示成功,不然会被记录在错误日志中。 * 参数peer表示发来请求的对端状况。其中 Server 对象的指针,能够用来调用 Reply(), * Inform() 等方法。若是是监听多个服务器,server 参数则会是不一样的对象。 */
virtual int Process(const Request& request, const Peer& peer,
Server* server);
/** * 关闭清理处理器所占用的资源 */
virtual int Close();
};
复制代码
设计完 Transport/Protocol/Processor 三个通讯处理层次后,就须要一个组合这三个层次的代码,那就是 Server 类。这个类在 Init() 的时候,须要上面三个类型的子类做为参数,以组合成不一样功能的服务器,如:
TlvProtocol tlv_protocol; // Type Length Value 格式分包协议,须要和客户端一致
TcpTransport tcp_transport; // 使用 TCP 的通讯协议,默认监听 0.0.0.0:6666
EchoProcessor echo_processor; // 业务逻辑处理器
Server server; // DenOS 的网络服务器主对象
server.Init(&tcp_transport, &tlv_protocol, &echo_processor); // 组装一个游戏服务器对象:TLV 编码、TCP 通讯和回音服务
复制代码
Server 类型还须要一个 Update() 函数,让用户进程的“主循环”不停的调用,用来驱动整个程序的运行。这个 Update() 函数的内容很是明确:
另外,Server 还须要处理一些额外的功能,好比维护一个会话缓存池(Session),提供发送 Response 和 Notice 消息的接口。当这些工做都完成后,整套系统已经能够用来做为一个比较“通用”的网络消息服务器框架存在了。剩下的就是添加各类 Transport/Protocol/Processor 子类的工做。
class Server {
public:
Server();
virtual ~Server();
/** * 初始化服务器,须要选择组装你的通讯协议链 */
int Init(Transport* transport, Protocol* protocol, Processor* processor, Config* config = NULL);
/** * 阻塞方法,进入主循环。 */
void Start();
/** * 须要循环调用驱动的方法。若是返回值是0表示空闲。其余返回值表示处理过的任务数。 */
virtual int Update();
void ClosePeer(Peer* peer, bool is_clear = false); //关闭当个链接,is_clear 表示是否最终总体清理
/** * 关闭服务器 */
void Close();
/** * 对某个客户端发送通知消息, * 参数peer表明要通知的对端。 */
int Inform(const Notice& notice, const Peer& peer);
/** * 对某个 Session ID 对应的客户端发送通知消息,返回 0 表示能够发送,其余值为发送失败。 * 此接口能支持断线重连,只要客户端已经成功链接,并使用旧的 Session ID,一样有效。 */
int Inform(const Notice& notice, const std::string& session_id);
/** * 对某个客户端发来的Request发回回应消息。 * 参数response的成员seqid必须正确填写,才能正确回应。 * 返回0成功,其它值(-1)表示失败。 */
int Reply(Response* response, const Peer& peer);
/** * 对某个 Session ID 对应的客户端发送回应消息。 * 参数 response 的 seqid 成员系统会自动填写会话中记录的数值。 * 此接口能支持断线重连,只要客户端已经成功链接,并使用旧的 Session ID,一样有效。 * 返回0成功,其它值(-1)表示失败。 */
int Reply(Response* response, const std::string& session_id);
/** * 会话功能 */
Session* GetSession(const std::string& session_id = "", bool use_this_id = false);
Session* GetSessionByNumId(int session_id = 0);
bool IsExist(const std::string& session_id);
};
复制代码
有了 Server 类型,确定也须要有 Client 类型。而 Client 类型的设计和 Server 相似,但就不是使用 Transport 接口做为传输层,而是 Connector 接口。不过 Protocol 的抽象层是彻底重用的。Client 并不须要 Processor 这种形式的回调,而是直接传入接受数据消息就发起回调的接口对象 ClientCallback。
class ClientCallback {
public:
ClientCallback() {
}
virtual ~ClientCallback() {
// Do nothing
}
/**
* 当链接创建成功时回调此方法。
* @return 返回 -1 表示不接受这个链接,须要关闭掉此链接。
*/
virtual int OnConnected() {
return 0;
}
/**
* 当网络链接被关闭的时候,调用此方法
*/
virtual void OnDisconnected() { // Do nothing
}
/**
* 收到响应,或者请求超时,此方法会被调用。
* @param response 从服务器发来的回应
* @return 若是返回非0值,服务器会打印一行错误日志。
*/
virtual int Callback(const Response& response) {
return 0;
}
/**
* 当请求发生错误,好比超时的时候,返回这个错误
* @param err_code 错误码
*/
virtual void OnError(int err_code){
WARN_LOG("The request is timeout, err_code: %d", err_code);
}
/**
* 收到通知消息时,此方法会被调用
*/
virtual int Callback(const Notice& notice) {
return 0;
}
/**
* 返回此对象是否应该被删除。此方法会被在 Callback() 调用前调用。
* @return 若是返回 true,则会调用 delete 此对象的指针。
*/
virtual bool ShouldBeRemoved() {
return false;
}
};
class Client : public Updateable {
public:
Client(); virtual ~Client();
/**
* 链接服务器
* @param connector 传输协议,如 TCP, UDP ...
* @param protocol 分包协议,如 TLV, Line, TDR ...
* @param notice_callback 收到通知后触发的回调对象,若是传输协议有“链接概念”(如TCP/TCONND),创建、关闭链接时也会调用。
* @param config 配置文件对象,将读取如下配置项目:MAX_TRANSACTIONS_OF_CLIENT 客户端最大并发链接数; BUFFER_LENGTH_OF_CLIENT客户端收包缓存;CLIENT_RESPONSE_TIMEOUT 客户端响应等待超时时间。
* @return 返回 0 表示成功,其余表示失败
*/
int Init(Connector* connector, Protocol* protocol,
ClientCallback* notice_callback = NULL, Config* config = NULL);
/**
* callback 参数能够为 NULL,表示不须要回应,只是单纯的发包便可。
*/
virtual int SendRequest(Request* request, ClientCallback* callback = NULL);
/**
* 返回值表示有多少数据须要处理,返回-1为出错,须要关闭链接。返回0表示没有数据须要处理。
*/
virtual int Update();
virtual void OnExit();
void Close();
Connector* connector() ;
ClientCallback* notice_callback() ;
Protocol* protocol() ;
};
复制代码
至此,客户端和服务器端基本设计完成,能够直接经过编写测试代码,来检查是否运行正常。
此文已由腾讯云+社区在各渠道发布,一切权利归做者全部
获取更多新鲜技术干货,能够关注咱们腾讯云技术社区-云加社区官方号及知乎机构号