从Socket到WebSocket

前言

        不知道大家在学习网络编程的时候都是怎样的一种方式,我谨以此文章来记录我自己从头开始学习C++网络编程时的经历,中间有许多我自己的一些想法和思考。当然作为一个刚开始学习的新手来说,有些内容也许不那么正确,只是代表了我在写这篇文章时的看法。对错相信看到本篇文章的人也能够通过自己的知识自行判断。

关于Socket

        我在真正接触TCP/IP网络编程之前,我曾经粗略的浏览过Linux系统编程的课程,当时这门课时长不长,整体介绍了进程线程的概念,涉及了管道,共享内存,消息队列,信号量等知识。并且在课程最后给我们实现了最基本TCP的双端通信。可以说我学习TCP/IP网络编程时已经不算真正意义上的小白了。虽然我希望尽我所能的将这些东西以更简单的方式讲清楚(因为我在学习的时候就发现了网上的大部分教程都是不知道在那里复制了一点相关概念,然后就直接给出了很它们的代码,然而并不会解释这些代码是如何产生的),但是我还是希望你们在学习网络编程之前可以看看相关Linux方面的知识,不至于理解不了常用的名词。

        注意,本片文章着重讲述学习完成socket后向websocket过渡的过程,所以本篇不会着重讲解socket编程,此外这篇文章所有的代码实现均在Linux上实现。

        在开始学习TCP/IP网络编程的阶段,有一本书绕不过去,那就是那本由韩国人尹圣雨所编写的《TCP/IP网络编程》,这本书可以说是我学习网咯编程的启蒙老师,这本书最大的有点就是讲解清晰易懂,示例代码均可以运行,并且注释非常清楚。可以说如果你是刚刚接触网络编程,这本书能够很好的带你入门。我是比较推荐新手看看这本书的。

认识WebSocket

        如果你已经看完了《TCP/IP网络编程》这本书的Linux部分,那么证明你已经有了一些socket编程基础。这时候可能你恰巧需要使用websocket,于是就开始在网络上搜索websocket相关的教程,寄希望发现某个教程能够从最基础开始讲解websocket,从而帮助自己在第一次接触websocket的情况下理解websocket。

        很可惜你失败了,确实找到了一些教程,但是都不是你所需要的。这些教程要不然就是对websocket协议的照本宣科的描述,毫无价值;要么就是使用的js等和java相关的代码来编写,不仅代码中莫名奇妙会出现一些不知道从那里凭空产生的websocket类,而且就连发送接收消息这种websocket协议中的关键性代码的详解也是没有的。

        同样的,我在学习websocket时也遇到了上面的问题,所以我决定自己来写一份使用C++语言的websocket学习记录,希望能够帮助到你们。所以说在这篇文章之后学习websocket的人们是幸运的,因为你们极有可能看到我的这篇文章而免受搜索到大量无用文章的痛苦。

使用websocket第三方库

        如果你尝试使用ChatGPT等AI工具帮你生成一个websocket服务端代码,它们大概率会给出一小段非常精简,并且易于理解的代码。大部分这类代码使用了websocket的第三方库,第三方库将具体的实现方法进行了封装,使得我们在使用起来特别方便。我个人是比较喜欢直接使用boost库来实现websocket服务的。下面我给出一段服务器代码例子

#include <iostream>
#include <boost/asio.hpp>
#include <boost/beast.hpp>

int main() {
    // 创建IO上下文
    boost::asio::io_context io_context;

    // 创建TCP端口并监听在9002端口
    boost::asio::ip::tcp::acceptor a(io_context, { boost::asio::ip::tcp::v4(), 9002 });

    // 创建TCP端口并绑定到特定IP地址和端口
    // boost::asio::ip::tcp::endpoint endpoint(boost::asio::ip::make_address("192.168.32.124"), 9002);
    // boost::asio::ip::tcp::acceptor a(io_context, endpoint);

    while (true) {
        // 接受客户端连接
        boost::asio::ip::tcp::socket socket(io_context);
        a.accept(socket);

        try {
            // 使用WebSocket流包装TCP套接字
            boost::beast::websocket::stream<boost::asio::ip::tcp::socket&> ws{ socket };

            // 启动WebSocket握手
            ws.accept();

            // 发送消息给客户端
            std::string response = "Hello, WebSocket!";
            ws.write(boost::asio::buffer(response));

            while (true) {
                // 读取客户端发送的消息
                boost::beast::flat_buffer buffer;
                ws.read(buffer);
                std::string received_msg = boost::beast::buffers_to_string(buffer.data());

                // 输出接收到的消息
                std::cout << "Received message from client: " << received_msg << std::endl;

                // 如果收到 "quit" 消息,则关闭连接
                if (received_msg == "quit") {
                    break;
                }

                // 发送消息给客户端
                response = "I received your message: " + received_msg;
                ws.write(boost::asio::buffer(response));
            }

            // 关闭WebSocket连接
            ws.close(boost::beast::websocket::close_code::normal);
        }
        catch (const boost::beast::system_error& se) {
            std::cerr << "Error: " << se.what() << std::endl;
        }
    }

    return 0;
}

        使用第三方库提供的函数能够大大减少我们编写代码的难度,但是作为初学者对于理解websocket协议点三方库就不是那么友好了,因为我们不容易看到具体的处理细节。

使用原生的C++socket编程

        在学习websocket时我们知道,websocket的实质其实还是基于socket进行通信的,只不过在通信的开始需要确认一下请求信息。在确认请求信息过后,之后的数据收发完全就是socekt通信。唯一需要注意的就是收到的数据并不是之前我们的那种简单的字符串了,websocket发送的数据是一个数据帧,简单来说就是我们收到的字符串中包含的不仅仅只有数据,他是遵循websocket协议的的不同字段的拼接。

        简单解释一下这个收到的数据块(也就是字符串),其具体结构如下:

  • FIN(1位):表示这是消息的最后一个数据帧,如果消息可以被分割成多个数据帧,那么只有最后一个数据帧的FIN位为1,其他数据帧的FIN位为0。
  • RSV1, RSV2, RSV3(各1位):预留位,目前没有特定的使用规范,一般情况下应该为0。
  • Opcode(4位):表示数据帧的类型,包括文本帧、二进制帧、关闭连接帧等。
  • Mask(1位):标识是否对数据进行掩码处理,客户端发往服务器的数据帧需要进行掩码处理,而服务器发往客户端的数据帧不需要进行掩码处理。
  • Payload length(7位或16位或64位):表示负载数据的长度,如果长度在0~125字节之间,则使用7位表示;如果长度在126~65535字节之间,则使用16位表示,且紧随其后的两个字节表示真正的长度;如果长度超过65535字节,则使用64位表示。
  • Masking key(0或4字节):如果Mask为1,那么会有4字节的掩码密钥,用于对负载数据进行掩码处理。
  • Payload data(x字节):实际的负载数据内容,长度由Payload length字段指定,如果Mask为1,则需要使用Masking key对这部分数据进行解码。

        这里面我们最需要注意的就是Opcode,Payload length。这个两个字段决定了我们在解析收到的数据的流程。

Opcode, 长度为 4 比特, 该字段将指示 frame 的类型, RFC 6455 定义的 Opcode 共有如下几种:

  • 0x0, 代表当前是一个 continuation frame
  • 0x1, 代表当前是一个 text frame
  • 0x2, 代表当前是一个 binary frame
  • 0x3 ~ 7, 目前保留, 以后将用作更多的非控制类 frame
  • 0x8, 代表当前是一个 connection close, 用于关闭 WebSocket 连接
  • 0x9, 代表当前是一个 ping frame (将在下面讨论)
  • 0xA, 代表当前是一个 pong frame (将在下面讨论)
  • 0xB ~ F, 目前保留, 以后将用作更多的控制类 frame

        实际上最需要记得的就是0x00,0x01,0x02,0x08,分别对应着中间数据,文本数据,二进制数据,和关闭websocket。在收到字符串时,我们首先要取出字符串里面的Opcode,看其符合哪一种,然后依照每种的处理方式继续进行后续数据的处理工作。

        我们这次使用的代码是以收到文本数据为例,下面是一个服务端代码,客户端我们可以使用websocket测试网站websocket在线测试。

#include <iostream>
#include <string>
#include <cstring>
#include <openssl/sha.h>
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <netinet/in.h>
#include <unistd.h>

std::string generate_handshake_response(const std::string& key) {
    const std::string GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    std::string concatenated = key + GUID;
    unsigned char hash[SHA_DIGEST_LENGTH];
    SHA1(reinterpret_cast<const unsigned char*>(concatenated.c_str()), concatenated.length(), hash);

    unsigned char encoded_hash[SHA_DIGEST_LENGTH*2]; // 预留足够的空间以容纳编码后的结果
    int encoded_length = EVP_EncodeBlock(encoded_hash, hash, SHA_DIGEST_LENGTH);

    std::string response_key(reinterpret_cast<char*>(encoded_hash), encoded_length);

    std::string response = "HTTP/1.1 101 Switching Protocols\r\n";
    response += "Upgrade: websocket\r\n";
    response += "Connection: Upgrade\r\n";
    response += "Sec-WebSocket-Accept: " + response_key + "\r\n\r\n";
    return response;
}

// 解析 WebSocket 消息内容
std::string parseWebSocketMessage(const std::string& message) {
    std::string decodedMessage;
    
    // 检查是否有掩码
    bool masked = (message[1] & 0x80) != 0;
    int payloadLength = message[1] & 0x7F;
    int maskOffset = 2;
    
    if (payloadLength == 126) {
        // 16位长度
        payloadLength = (static_cast<uint8_t>(message[2]) << 8) | static_cast<uint8_t>(message[3]);
        maskOffset = 4;
    } else if (payloadLength == 127) {
        // 64位长度,我们假设消息不会很大
        payloadLength = (static_cast<uint64_t>(message[2]) << 56) |
                        (static_cast<uint64_t>(message[3]) << 48) |
                        (static_cast<uint64_t>(message[4]) << 40) |
                        (static_cast<uint64_t>(message[5]) << 32) |
                        (static_cast<uint64_t>(message[6]) << 24) |
                        (static_cast<uint64_t>(message[7]) << 16) |
                        (static_cast<uint64_t>(message[8]) << 8) |
                        static_cast<uint64_t>(message[9]);
        maskOffset = 10;
    }
    
    std::string maskingKey = message.substr(maskOffset, 4);
    std::string payload = message.substr(maskOffset + 4, payloadLength);
    
    // 反掩码操作
    for (int i = 0; i < payloadLength; ++i) {
        decodedMessage.push_back(payload[i] ^ maskingKey[i % 4]);
    }
    
    return decodedMessage;
}

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};

    // 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(9002);

    // 绑定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }

    std::cout << "WebSocket server is listening on port 9002" << std::endl;

    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }

    // 接收握手请求
    int valread = read(new_socket, buffer, 1024);
    std::string key;
    std::string handshake_request(buffer, valread);
    std::size_t found = handshake_request.find("Sec-WebSocket-Key: ");
    if (found != std::string::npos) {
        key = handshake_request.substr(found + 19, 24);
        std::string response = generate_handshake_response(key);
        send(new_socket, response.c_str(), response.length(), 0);
        std::cout << "WebSocket handshake completed." << std::endl;
    }
    // std::cout << key << std::endl;

    // 接收和发送WebSocket消息
    while (true) {
        // memset(buffer,'\0',1024);
        valread = read(new_socket, buffer, 1024);

        // 处理WebSocket数据帧,这部分需要根据WebSocket协议规范进行解析
        unsigned char opcode = buffer[0] & 0x0F;
            switch (opcode) {
                case 0x01:  // 文本消息
                    {
                        // 解析文本消息内容
                        std::string str(buffer);
                        std::string message = parseWebSocketMessage(str);

                        //在服务器端回显
                        std::cout << "Received message: " << message << std::endl;
                        //假设收到的数据是文本消息,直接返回相同的消息
                        /*        
                            这行代码构造了返回消息。
                            首先,\x81 是WebSocket帧的控制位,表示这是一个文本消息帧。
                            然后是消息的长度信息,它的长度是一个字节,表示消息的长度。接着是消息的实际内容。
                        */
                        std::string response = std::string("\x81", 1) + static_cast<char>(message.length()) + message;
                        send(new_socket, response.c_str(), response.length(), 0);

                        break;
                    }
                case 0x08:  // 关闭连接
                    {
                        // 处理关闭连接请求
                        // handleCloseRequest();
                        close(new_socket);
                        // close(server_fd);
                        // std::cout << "closed\n";
                        break;
                    }
                // 其他消息类型的处理
                default:
                    {
                        // 其他处理逻辑
                        break;
                    }                
            }        
    }
    return 0;
}

        大家可以看到在我们的主函数中,前面一直都是按照常规的socket编程进行,但是在accept之后,我们对首次接收到的数据(也叫做接收请求头)进行了提取Sec-WebSocket-Key:的操作,并对提取出来的Sec-WebSocket-Key与 WebSocket 魔数 (Magic Number) "258EAFA5-E914-47DA- 95CA-C5AB0DC85B11" 进行字符串连接, 将得到的字符串做 SHA-1 哈希, 将得到的哈希值再做 base64 编码,最终我们把得到的值作为服务器向客户端发送的Sec-WebSocket-Accept:字段值。并且一同发送给客户端的还有
    "HTTP/1.1 101 Switching Protocols\r\n";
    "Upgrade: websocket\r\n";
    "Connection: Upgrade\r\n";

完成这样一来一回的请求回应操作后,websocket服务端和客户端就正式建立起连接了。

        在代码中我们建立连接以后,主函数进入while死循环,不断接收发送websocket消息。我们下面思考这段代码的逻辑。正如上面介绍数据帧格式的时候所说,首先我们获取数据帧中Opcode的部分,这里使用的是unsigned char opcode = buffer[0] & 0x0F;使用与操作将opcode数据给取了出来,之后使用switch语句判断opcode属于那种情况,这里我实现了0x01文本数据的收发操作。

        确定了数据发送过来的是文本数据,那么这个文本数据有多长,得到数据长度后我们就可以打印输出到服务器上或者回传给客户端了吗。显然远没有这么简单,RFC 6455 规定所有由客户端发往服务端的 WebSocket frame 的 Payload 部分都必须使用掩码覆盖。也就是说我们收到的数据是经过掩码覆盖的加密数据,直接使用会不能识别,必须经过解码操作。得到未被覆盖前的原始数据。

掩码覆盖的算法如下:

  1. 客户端使用熵值足够高的随机数生成器随机生成 32 比特的 Masking-Key
  2. 以字节为步长遍历 Payload, 对于 Payload 的第 i 个字节, 首先做 i MOD 4 得到 j, 则掩码覆盖后的 Payload 的第 i 个字节的值为原先 Payload 第 i 个字节与 Masking-Key 的第 j 个字节做按位异或操作

    // 反掩码操作
    for (int i = 0; i < payloadLength; ++i) {
        decodedMessage.push_back(payload[i] ^ maskingKey[i % 4]);
    }

经过反掩码之后我们就得到了正确的原始数据,使用原始数据就可以正常向客户端发送了。

        看到这里或许你已经又发现了一些特别的地方,是的,原始数据只能用于服务器显示,并不能直接send到客户端,究其原因是因为websocket的收发双方都必须发送严格遵循websocket数据帧格式的数据,所以我们也必须构建出同样的字符串才行,代码中使用std::string response = std::string("\x81", 1) + static_cast<char>(message.length()) + message;这行代码构造了发往客户端的数据。

结语

        至此我们已经完成了一个最基本的websocket服务器模型,相信对初学者的你来说,成功发送并接收websocket数据一定十分兴奋,当然对于之后的学习也不要懈怠。如果本篇教程帮到了你,那么我也感到非常开心。希望我们下下篇博客再见。

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

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

相关文章

flink 操作mongodb的例子

简述 该例子为从一个Collection获取数据然后插入到另外一个Collection中。 Flink的基本处理过程可以清晰地分为以下几个阶段&#xff1a; 数据源&#xff08;Source&#xff09;&#xff1a; Flink可以从多种数据源中读取数据&#xff0c;如Kafka、RabbitMQ、HDFS等。Flink会…

通过 CXCR4 靶向纳米线修补细胞表面以抑制癌症转移

引用信息 文 章&#xff1a;Cell surface patching via CXCR4-targeted nanothreads for cancer metastasis inhibition. 期 刊&#xff1a;Nature Communications&#xff08;影响因子&#xff1a;16.6&#xff09; 发表时间&#xff1a;2024/3/29 作 者&#xff1…

基于springboot实现大学生一体化服务平台系统项目【项目源码+论文说明】计算机毕业设计

基于springboot实现大学生一体化服务平台系统演示 摘要 如今社会上各行各业&#xff0c;都喜欢用自己行业的专属软件工作&#xff0c;互联网发展到这个时候&#xff0c;人们已经发现离不开了互联网。新技术的产生&#xff0c;往往能解决一些老技术的弊端问题。因为传统大学生综…

vivado 时序约束

时间限制 以下ISE设计套件时序约束可以表示为XDC时序约束 Vivado设计套件。每个约束描述都包含一个UCF示例和 等效的XDC示例。 在未直接连接到边界的网络上创建时钟时&#xff0c;UCF和XDC不同 的设计&#xff08;如端口&#xff09;。在XDC中&#xff0c;当在上定义带有create…

LwIP 之十 详解 TCP RAW 编程、示例、API 源码、数据流

我们最为熟知的网络通信程序接口应该是 Socket。LwIP 自然也提供了 Socket 编程接口,不过,LwIP 的 Socket 编程接口都是使用最底层的接口来实现的。我们这里要学习的 TCP RAW 编程则是指的直接使用 LwIP 的最底层 TCP 接口来直接实现应用层功能。这里先来一张图,对 LwIP 内部…

【JS重点知识02】栈、堆与数据类型 关系

一&#xff1a;栈堆空间分配区别&#xff1a; 1 栈&#xff1a;由操作系统自动分配释放存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈&#xff1b; 简单数据类型存放在栈中 2 堆&#xff1a;存储复杂数据类型&#xff08;对象&#xff09;&#xff0c;…

C#知识|通用数据访问类SQLHelper的编写

哈喽,你好啊,我是雷工! 前面学习了C#操作SQLServer数据库数据的增删改查, 《C#知识|通过ADO.NET实现应用程序对数据库的增、删、改操作。》 其中实现的过程中有很多代码是重复的,此时作为高阶程序员为了使代码更优雅,避免重复, 从而要考虑代码的复用技术,针对此案例中代…

vscode 编程工具配置Java开发环境

vs code 开发环境配置。 环境准备&#xff1a; 1. 安装JDK/安装maven/安装vs code 首先安装好vs code 之后&#xff0c;需要安装 Extension Pack for Java 这么个插件 配置maven&#xff0c;进入setting&#xff0c; 3&#xff1a;配置 maven安装目录&#xff0c;4&#xff1a…

【机器学习】深入探索机器学习:利用机器学习探索股票价格预测的新路径

❀机器学习 &#x1f4d2;1. 引言&#x1f4d2;2. 多种机器学习算法的应用&#x1f4d2;3. 机器学习在股票价格预测中的应用现状&#x1f389;数据收集与预处理&#x1f389;模型构建与训练&#x1f308;模型评估与预测&#x1f31e;模型评估&#x1f319;模型预测⭐注意事项 &…

msvcp100.dll丢失怎样修复?几种快速有效修复msvcp100.dll丢失的方法

在使用电脑时是不是遇到过关于msvcp100.dll丢失文件丢失的情况&#xff1f;出现这样的情况有什么办法可以将丢失的msvcp100.dll文件快速恢复&#xff1f;今天的这篇文章就将教大家几种能够有效的解决msvcp100.dll丢失问题的方法。 方法一&#xff1a;重启电脑 重启电脑是一种简…

sqliteSQL基础

SQL基础 SQLite 数据库简介 SQLite 是一个开源的、 内嵌式的关系型数据库&#xff0c; 第一个版本诞生于 2000 年 5 月&#xff0c; 目前最高版本为 SQLite3。 下载地址&#xff1a; https://www.sqlite.org/download.html 菜鸟教程 : https://www.runoob.com/sqlite/sqlit…

项目VS运营

一、项目与运营的定义与区别 项目与运营是企业管理中的两个重要概念&#xff0c;尽管在实际运作中它们常被视为同义词&#xff0c;但它们之间存在明显的区别。 项目&#xff0c;指的是为达到特定目标&#xff0c;通过临时性、系统性、有计划的组织、协调、控制等系列活动&…

活动预告|6月13日Apache Flink Meetup·香港站

6 月 13 日 | 香港 | 线下 Apache Flink Meetup 的风吹到了香江之畔&#xff0c;Apache Flink 香港 Meetup 来啦&#xff01;本次活动&#xff0c;我们邀请了来自阿里云的顶尖专家&#xff0c;帮助开发者全面了解 Apache Flink 的流批一体的数据处理能力&#xff0c;流式数据湖…

第100+9步 ChatGPT文献复现:ARIMA预测百日咳

基于WIN10的64位系统演示 一、写在前面 我们来继续换一篇文章来学习学习&#xff1a; 《BMC Public Health》杂志的2022年一篇题目为《ARIMA and ARIMA-ERNN models for prediction of pertussis incidence in mainland China from 2004 to 2021》文章的模拟数据做案例。 这…

源码编译安装LNMP

1、LNMP 包含&#xff1a;linux、Nginx、Mysql、php LNMP的工作原理 由客户端发送页面请求给Nginx&#xff0c;Nginx会根据location匹配用户访问请求的URL路径判断是静态还是动态&#xff0c;静态的一般是以 .html .htm .css .shtml结尾&#xff0c;动态的一般是以 .php .jsp…

【测试】linux快捷指令工具cxtool

简介 登录linux时,我们经常需要重复输入一些指令. 这个工具可以把这些指令预置,需要的时候鼠标一点,会自动按预置的字符敲击键盘,敲击出指令. 下载地址 https://download.csdn.net/download/bandaoyu/89379371 使用方法 1,编辑配置文件&#xff0c;自定义自己的快捷指令。 2…

PMP证书有用吗?到底要不要报名?

证书就是&#xff0c;适用者自有用&#xff0c;不适者无用。对于做管理之类的人士考个PMP必然有用。 首先PMP是什么? PMP指的是项目管理专业人士资格认证。那怎么来定义“项目”?“项目“可以简单的理解为:在给定的费用与时间约束范围之内&#xff0c;完成意向独立的、一次…

2024年5月软件设计师选择题答案(持续更新~)

题目1【考生回忆版】在计算机网络协议5层体系结构中&#xff0c;()工作在数据链路层 A.路由器 B.以太网交换机 C.防火墙 D.集线器 题目2【考生回忆版】软件交付之后&#xff0c;由于软硬件环境发生变化而对软件进行修改的行为属于()维护。 A.改善性 B.适应性 C.预防性 …

JVM之【运行时数据区1】

JVM简图 运行时数据区简图 一、程序计数器&#xff08;Program Counter Register&#xff09; 1.程序计数器是什么&#xff1f; 程序计数器是JVM内存模型中的一部分&#xff0c;它可以看作是一个指针&#xff0c;指向当前线程所执行的字节码指令的地址。每个线程在执行过程中…

『ZJUBCA MeetUP』 5月25日线下活动——Aptos 链的动态与应用

2024 求是创新 ZJUBCA Sponsored by the ALCOVE Community TIME&#xff1a;2024/05/25 ADD&#xff1a;浙江大学紫金港校区 --- Alcove 是 Aptos 公链与 Alibaba Cloud 共同打造的亚洲首个 Move 开发者社区&#xff0c;致力于支持开发者使用 Move 语言构建下一代 Web3 应用&am…