项目实战 — 消息队列(8){网络通信设计①}

目录

一、自定义应用层协议

🍅 1、格式定义

🍅 2、准备工作

🎄定义请求和响应 

 🎄 定义BasicArguments

🎄 定义BasicReturns

🍅 2、创建参数类

        🎄 交换机

        🎄 队列

        🎄 绑定

        🎄发布消息

        🎄 订阅消息

        🎄确认应答

        🎄 消息推送

二、服务器设计

 🍅 1、编写实例变量和构造方法

🍅 2、编写启动类和关闭类

🍅 3、编写处理连接的方法:processConnection()

 🍅 4、编写读取请求readRequest()和写回响应writeResponse方法

🍅 5、实现根据请求计算响应:process()方法编写


一、自定义应用层协议

🍅 1、格式定义

本消息队列,是需要通过网络进行通信的。这里主要基于TCP协议,自定义应用层协议。

由于当前交互的Message数据,是二进制数据,由于HTTP和JSON都是文本协议,所以这里就不适用了。使用自定义的应用层协议。

约定自定义应用层协议格式:

        以下是请求和响应的组成部分:

 type:

描述当前请求和响应式做什么的,描述当前请求/响应是在调用哪个API(VirtualHost中的核心API)

        以下是type标识请求相应不同的功能,取值如下:

        其中Channel代表的是Connection(TCP的连接)内部的”逻辑上"的连接。此时一个           Connection中可能会含有多个Channel。存在的意义是为了让TCP连接

VirtualHost中的十多个方法:
0x1创建channel
0x2关闭channel
0x3创建exchange
0x4销毁exchange
0x5创建queue
0x6销毁queue
0x7创建binding
0x8销毁binding
0x9发送message
0xa订阅message
0xb返回ack
0xc服务器给客户端推送的消息(被订阅的消息)(响应独有)

length:描述了payload的长度

payload: 会根据当前是请求还是响应,以及当前的type有不同的取值。

比如当前是0x3(创建交换机),

/*
* 表示一个网络通信中的请求对象,按照自定义协议的格式来展开
* */
@Data
public class Request {private int type;private int length;private byte[] payload;
}

当前是一个请求,那么pyload中的内容是exchangeDeclare的参数的序列化的结果;

如果当前是一个响应,那么payload里面的内容就是exchangeDeclare的返回结果的序列化内容。

那么接下来就进行代码设计

以下都是再commen包中创建。

🍅 2、准备工作

🎄定义请求和响应 

/*
* 表示一个网络通信中的请求对象,按照自定义协议的格式来展开
* */
@Data
public class Request {private int type;private int length;private byte[] payload;
}
/*
* 表示一个网络通信中的响应对象,也是根据自定义应用层协议来的
* */
@Data
public class Response {private int type;private int length;private byte[] payload;
}

 🎄 定义BasicArguments

使用这个类表示方法的公共参数/辅助的字段 ,后续的每个方法会有一些不同的参数,不同的参数再使用不同的子类来表示。

rid代表请求的id,和响应的id一样,他们是一对

channel表示的是“逻辑连接”,表示客户端各种模块复用一个TCP连接,

channelId就代表这些连接。

@Data
public class BasicArguments implements Serializable {
//     表示一次请求/响应的身份标识,可以把请求和响应对上protected String rid;
//    客户端的身份标识protected String channelId;
}

🎄 定义BasicReturns

使用这个类标识各个远程调用的方法的返回值的公共信息

/*
* 标识各个远程调用的方法的返回值的公共信息
* */
@Data
public class BasicReturns implements Serializable {
//    用来标识唯一的请求和响应protected String rid;protected String channelId;
//    用来表示当前远程调用方法的返回值protected boolean ok;
}

🍅 2、创建参数类

根据前面VirtualHost中的十多个方法,每个方法创建一个类,标识该方法中的相关参数。

那么这个参数到底是如何进行传递的?

如下图,以交换机的参数进行举例。

关于我们远程调用的过程:当发起请求时,就把这些参数通过请求传过去,然后调用VirtualHost中的API(就是VirtualHost中的那些创建删除方法),调用完以后再返回响应。

以下是有关交换机的请求报文:

以下是创建交换机的响应报文:没有请求报文复杂是因为,响应只需要返回请求是否执行远程调用是否成功即可。 

以下就创建这些参数类: 

        🎄 交换机

 创建交换机:

@Data
public class ExchangeDeclareArguments extends BasicArguments implements Serializable {private String ExchangeName;private ExchangeType exchangeType;private boolean durable;
}

删除交换机:

@Data
public class ExchangeDeleteArguments extends BasicArguments implements Serializable {private String exchangeName;
}

        🎄 队列

创建队列:

@Data
public class QueueDeclareArguments extends BasicArguments implements Serializable {private String QueueName;private boolean durable;
}

删除队列:

@Data
public class QueueDeleteArguments extends BasicArguments implements Serializable {private String queueName;
}

        🎄 绑定

创建绑定:

@Data
public class QueueBindArguments extends BasicArguments implements Serializable {private String exchangeName;private String queueName;private String bindingKey;
}

删除绑定:

@Data
public class QueueUnbindArguments extends BasicArguments implements Serializable {private String queueName;private String exchangeName;
}

        🎄发布消息

@Data
public class BasicPublishArguments extends BasicArguments implements Serializable {private String exchangeName;private String routingKey;private BasicProperties basicProperties;private byte[] body;
}

        🎄 订阅消息

这个方法参数,还包含一个Consumer consumer。

这是一个回调函数,这个回调函数是不能作为参数进行传输的,因为这个回调函数,是客户端这边的。

比如,这里请求调用一个”订阅队列“的远程方法,

客户端这边:服务器收到了请求,执行了basicConsume方法,并且返回了响应。订阅以后,客户端的消费者就会在后面收到消息,而这个回调函数是在消费者收到消息以后,才会进行逻辑处理,而不是再发送请求时进行传递的。

服务器这边:执行的是一个固定的回调函数:把消息返回给客户端。

@Data
public class BasicConsumeArguments extends BasicArguments implements Serializable {private String consumerTag;private String queueName;private boolean autoAck;
}

        🎄确认应答

@Data
public class BasicAckArguments extends BasicArguments implements Serializable {private String queueName;private String messageId;
}

        🎄 消息推送

前面的都是客户端给服务器发送消息,这里是服务器给消费者推送消息。所以要继承BasicReturns。

@Data
public class SubScribeReturns extends BasicReturns implements Serializable {private String consumerTag;private BasicProperties basicProperties;private byte[] body;
}

二、服务器设计

在 mqServer包中创建一个BrokerServer类。

 🍅 1、编写实例变量和构造方法

 private ServerSocket serverSocket = null;private VirtualHost virtualHost = new VirtualHost("default");//    使用这个哈希表,表示当前所有会话(那些客户端在和这个服务器进行通信)
//    此处的key是channelId,value是对应的 socket对象private ConcurrentHashMap<String , Socket> sessions = new ConcurrentHashMap<String ,Socket>();//    引入线程池,处理多个客户端的请求private ExecutorService executorService = null;//    引入boolean变量控制服务器是否运行private volatile boolean runnable = true;public BrokerServer(int port) throws IOException {
//        端口号serverSocket = new ServerSocket(port);}

🍅 2、编写启动类和关闭类

 这里利用了线程池,不断的处理连接

public void start() throws IOException {System.out.println("[BrokerServer] 启动!");executorService = Executors.newCachedThreadPool();try {while (runnable) {Socket clientSocket = serverSocket.accept();// 把处理连接的逻辑丢给这个线程池.executorService.submit(() -> {processConnection(clientSocket);});}} catch (SocketException e) {System.out.println("[BrokerServer] 服务器停止运行!");// e.printStackTrace();}}public void stop() throws IOException {runnable = false;
//        停止线程池executorService.shutdownNow();serverSocket.close();}private void processConnection(Socket clientSocket) {//TODO
}

🍅 3、编写处理连接的方法:processConnection()

处理一个客户端的连接,主要有以下几步:

        (1)读取请求并且解析

        (2)根据请求计算响应

        (3)把相应协写回给客户端

//    通过该方法,处理一个客户端的连接
//    在一个连接中,可能会涉及到多个连接和请求private void processConnection(Socket clientSocket) throws IOException {
//        获取到流对象,读取应用层协议try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()){
//                按照特定格式来读取并且解析(转换),此时就需要用到DataInputStream和DataOutputStreamtry (DataInputStream dataInputStream = new DataInputStream(inputStream);DataOutputStream dataOutputStream = new DataOutputStream(outputStream)) {while (true) {
//                  1、读取请求并且解析Request request = readRequest(dataInputStream);
//                  2、根据请求计算响应Response response = process(request, clientSocket);
//                  3、把响应写回给客户端writeResponse(dataOutputStream,response);}}}catch (EOFException|SocketException e) {
//                DataInputStream如果读到EOF(文件末尾),会抛出一个EOFException异常
//                视为正常的异常,用或者异常来结束循环System.out.println("[BrokerServer] connection 关闭! 客户端的地址: " + clientSocket.getInetAddress().toString()+ ":" + clientSocket.getPort());} catch (IOException | ClassNotFoundException | MqException e) {System.out.println("[BrokerServer] connection 出现异常!");e.printStackTrace();} finally {try {clientSocket.close();
//          一个TCP连接中,可能含有多个channel,需要把当前socket对应的channel也顺便清理掉clearClosedSession(clientSocket);}catch (IOException e) {e.printStackTrace();}}}

 🍅 4、编写读取请求readRequest()和写回响应writeResponse方法

这里就是根据前面设定的报文格式来编写的读取请求和写回响应的方法,这里的payload的具体内容在这里不作解析,在后面的process方法中进行解析

//    读取请求并且解析private Request readRequest(DataInputStream dataInputStream) throws IOException {Request request = new Request();//        读取出请求中4个字节的typerequest.setType(dataInputStream.readInt());
//        读出4个字节的lengthrequest.setLength(dataInputStream.readInt());byte[] payload = new byte[request.getLength()];int n = dataInputStream.read(payload);if (n != request.getLength()){throw new IOException("读取请求格式出错");}request.setPayload(payload);return request;}//    把响应写回给客户端private void writeResponse(DataOutputStream dataOutputStream, Response response) throws IOException {dataOutputStream.writeInt(response.getType());dataOutputStream.writeInt(response.getLength());dataOutputStream.write(response.getPayload());
//        刷新缓冲区dataOutputStream.flush();}

🍅 5、实现根据请求计算响应:process()方法编写

这里就要针对具体的payload进行编写了。

当前请求中的payload里面的内容,是根据type来的,如下

VirtualHost中的十多个方法:
0x1创建channel
0x2关闭channel
0x3创建exchange
0x4销毁exchange
0x5创建queue
0x6销毁queue
0x7创建binding
0x8销毁binding
0x9发送message
0xa订阅message
0xb返回ack
0xc服务器给客户端推送的消息(被订阅的消息)(响应独有)

如果是0x3,就是创建交换机对应的参数...... 

主要分为以下几步:

        1、把request中的payload作出一个初步的解析

        2、根据type的值,进一步区分请求要做什么

        3、构造响应

private Response process(Request request, Socket clientSocket) throws IOException, ClassNotFoundException, MqException {// 1. 把 request 中的 payload 做一个初步的解析.BasicArguments basicArguments = (BasicArguments) BinaryTool.fromBytes(request.getPayload());System.out.println("[Request] rid=" + basicArguments.getRid() + ", channelId=" + basicArguments.getChannelId()+ ", type=" + request.getType() + ", length=" + request.getLength());// 2. 根据 type 的值, 来进一步区分接下来这次请求要干啥.boolean ok = true;if (request.getType() == 0x1) {// 创建 channelsessions.put(basicArguments.getChannelId(), clientSocket);System.out.println("[BrokerServer] 创建 channel 完成! channelId=" + basicArguments.getChannelId());} else if (request.getType() == 0x2) {// 销毁 channelsessions.remove(basicArguments.getChannelId());System.out.println("[BrokerServer] 销毁 channel 完成! channelId=" + basicArguments.getChannelId());} else if (request.getType() == 0x3) {// 创建交换机. 此时 payload 就是 ExchangeDeclareArguments 对象了.ExchangeDeclareArguments arguments = (ExchangeDeclareArguments) basicArguments;ok = virtualHost.exchangeDeclare(arguments.getExchangeName(), arguments.getExchangeType(),arguments.isDurable());} else if (request.getType() == 0x4) {
//        删除交换机ExchangeDeleteArguments arguments = (ExchangeDeleteArguments) basicArguments;ok = virtualHost.exchangeDelete(arguments.getExchangeName());} else if (request.getType() == 0x5) {
//            创建队列QueueDeclareArguments arguments = (QueueDeclareArguments) basicArguments;ok = virtualHost.queueDeclare(arguments.getQueueName(), arguments.isDurable());} else if (request.getType() == 0x6) {
//        删除队列QueueDeleteArguments arguments = (QueueDeleteArguments) basicArguments;ok = virtualHost.queueDelete((arguments.getQueueName()));} else if (request.getType() == 0x7) {
//            创建绑定QueueBindArguments arguments = (QueueBindArguments) basicArguments;ok = virtualHost.queueBind(arguments.getQueueName(), arguments.getExchangeName(), arguments.getBindingKey());} else if (request.getType() == 0x8) {//    删除绑定QueueUnbindArguments arguments = (QueueUnbindArguments) basicArguments;ok = virtualHost.queueUnbind(arguments.getQueueName(), arguments.getExchangeName());} else if (request.getType() == 0x9) {BasicPublishArguments arguments = (BasicPublishArguments) basicArguments;ok = virtualHost.basicPublish(arguments.getExchangeName(), arguments.getRoutingKey(),arguments.getBasicProperties(), arguments.getBody());} else if (request.getType() == 0xa) {BasicConsumeArguments arguments = (BasicConsumeArguments) basicArguments;ok = virtualHost.basicConsume(arguments.getConsumerTag(), arguments.getQueueName(), arguments.isAutoAck(),new Consumer() {// 这个回调函数要做的工作, 就是把服务器收到的消息可以直接推送回对应的消费者客户端@Overridepublic void handleDelivery(String consumerTag, BasicProperties basicProperties, byte[] body) throws MqException, IOException {// 先知道当前这个收到的消息, 要发给哪个客户端.// 此处 consumerTag 其实是 channelId. 根据 channelId 去 sessions 中查询, 就可以得到对应的// socket 对象了, 从而可以往里面发送数据了// 1. 根据 channelId 找到 socket 对象Socket clientSocket = sessions.get(consumerTag);if (clientSocket == null || clientSocket.isClosed()) {throw new MqException("[BrokerServer] 订阅消息的客户端已经关闭!");}// 2. 构造响应数据SubScribeReturns subScribeReturns = new SubScribeReturns();subScribeReturns.setChannelId(consumerTag);subScribeReturns.setRid(""); // 由于这里只有响应, 没有请求, 不需要去对应. rid 暂时不需要.subScribeReturns.setOk(true);subScribeReturns.setConsumerTag(consumerTag);subScribeReturns.setBasicProperties(basicProperties);subScribeReturns.setBody(body);byte[] payload = BinaryTool.toBytes(subScribeReturns);Response response = new Response();// 0xc 表示服务器给消费者客户端推送的消息数据.response.setType(0xc);// response 的 payload 就是一个 SubScribeReturnsresponse.setLength(payload.length);response.setPayload(payload);// 3. 把数据写回给客户端.//    注意! 此处的 dataOutputStream 这个对象不能 close !!!//    如果 把 dataOutputStream 关闭, 就会直接把 clientSocket 里的 outputStream 也关了.//    此时就无法继续往 socket 中写入后续数据了.DataOutputStream dataOutputStream = new DataOutputStream(clientSocket.getOutputStream());writeResponse(dataOutputStream, response);}});} else if (request.getType() == 0xb) {// 调用 basicAck 确认消息.BasicAckArguments arguments = (BasicAckArguments) basicArguments;ok = virtualHost.basicAck(arguments.getQueueName(), arguments.getMessageId());} else {// 当前的 type 是非法的.throw new MqException("[BrokerServer] 未知的 type! type=" + request.getType());}// 3. 构造响应BasicReturns basicReturns = new BasicReturns();basicReturns.setChannelId(basicArguments.getChannelId());basicReturns.setRid(basicArguments.getRid());basicReturns.setOk(ok);byte[] payload = BinaryTool.toBytes(basicReturns);Response response = new Response();response.setType(request.getType());response.setLength(payload.length);response.setPayload(payload);System.out.println("[Response] rid=" + basicReturns.getRid() + ", channelId=" + basicReturns.getChannelId()+ ", type=" + response.getType() + ", length=" + response.getLength());return response;}

🍅 6、清理过期的sessions:clearClosedSession()

    //    遍历sessions hash表,把该被关闭的socket对应的键值对都删掉private void clearClosedSession(Socket clientSocket) {List<String> toDeleteChannelId = new ArrayList<>();for(Map.Entry<String,Socket> entry : sessions.entrySet()){if(entry.getValue() == clientSocket){
//                使用集合类,不能一边遍历,一边删除toDeleteChannelId.add(entry.getKey());}}for (String channelId : toDeleteChannelId){sessions.remove(channelId);}System.out.println("[BrokerServer]清理session完成~ 被清理的channeId = " + toDeleteChannelId);}

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

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

相关文章

【网络】传输层——TCP(滑动窗口流量控制拥塞控制延迟应答捎带应答)

&#x1f431;作者&#xff1a;一只大喵咪1201 &#x1f431;专栏&#xff1a;《网络》 &#x1f525;格言&#xff1a;你只管努力&#xff0c;剩下的交给时间&#xff01; 上篇文章对TCP可靠性机制讲解了一部分&#xff0c;这篇文章接着继续讲解。 &#x1f3a8;滑动窗口 在…

Springboot 实践(2)MyEclipse2019创建项目修改pom文件,加载springboot 及swagger-ui jar包

MyEclipse2019创建工程之后&#xff0c;需要添加Springboot启动函数、添加application.yml配置文件、修改pom文件添加项目使用的jar包。 添加Springboot启动函数 创建文件存储路径 &#xff08;1&#xff09;右键单击“src/main/java”文件夹&#xff0c;弹出对话框输入路径…

Android 简单的视频、图片压缩工具

首页需要压缩的工具包 1.Gradle implementation com.iceteck.silicompressorr:silicompressor:2.2.3 2.添加相关权限&#xff08;手机得动态申请权限&#xff09; <uses-permission android:name"android.permission.READ_EXTERNAL_STORAGE"/> <uses-p…

05 - 研究 .git 目录

查看所有文章链接&#xff1a;&#xff08;更新中&#xff09;GIT常用场景- 目录 文章目录 1. HEAD2. config3. refs4. objects 1. HEAD 2. config 3. refs 4. objects Git对象一共有三种&#xff1a;数据对象 blob、树对象 tree以及提交对象 commit&#xff0c;这些对象都被保…

Vue 目录结构 vite 项目

Vue3 项目常用的目录结构和每个文件的作用【通过 vite 创建的项目】 vite目录结构&#xff1a; dist // 打包后生成的文件目录 node_modules // 环境依赖 public // 公共资源目录 favicon.ico …

深入探析设计模式:工厂模式的三种姿态

深入探析设计模式&#xff1a;工厂模式的三种姿态 1. 简单工厂模式1.1 概念1.2 案例1.3 优缺点 2. 抽象工厂模式2.1 概念2.2 案例&#xff1a;跨品牌手机生产2.3 优缺点 3. 超级工厂模式3.1 概念3.2 案例&#xff1a;动物园游览3.3 优缺点 4. 总结 欢迎阅读本文&#xff0c;今天…

go入门实践四-go实现一个简单的tcp-socks5代理服务

文章目录 前言socks协议简介go实现一个简单的socks5代理运行与压测抓包验证 前言 SOCKS是一种网络传输协议&#xff0c;主要用于客户端与外网服务器之间通讯的中间传递。协议在应用层和传输层之间。 本文使用先了解socks协议。然后实现一个socks5的tcp代理服务端。最后&#…

英语词法——代词

代词是用来代替名词、起名词作用的短语、分句和句子的词。英语中代词根据其意义和作用可分为九类:人称代词、物主代词、反身代词、相互代词、指示代词、疑问代词、不定代词、关系代词和连接代词。 第一节 人称代词 一、人称代词的形式和用法 人称代词单数复数第一人称第二人…

【ARM 嵌入式 编译系列 4 -- GCC 编译属性 __read_mostly 详细介绍】

文章目录 __read_mostly 介绍__read_mostly 在 linux 中的使用.data.read_mostly 介绍 __read_mostly 介绍 __read_mostly 是一个在Linux内核编程中用到的宏定义&#xff0c;这是一个gcc编译器的属性&#xff0c;用于告诉编译器此变量主要用于读取&#xff0c;很少进行写入&am…

MYSQL中用字符串2022-07去匹配Date类型大于2022-07-01并小于2022-07-31

正文 需求上&#xff0c;是有个日期字符串&#xff0c;例如2022-07&#xff0c;代表着年月。数据库中表对于这个字段存的是年月日&#xff0c;例如&#xff1a;2022-07-15。 我希望的是&#xff1a;获取到2022-07-01到2022-07-31&#xff0c;之间的数据&#xff0c;条件是&…

21款美规奔驰GLS450更换中规高配主机,汉化操作更简单

很多平行进口的奔驰GLS都有这么一个问题&#xff0c;原车的地图在国内定位不了&#xff0c;语音交互功能也识别不了中文&#xff0c;原厂记录仪也减少了&#xff0c;使用起来也是很不方便的。 可以实现以下功能&#xff1a; ①中国地图 ②语音小助手&#xff08;你好&#xf…

【BASH】回顾与知识点梳理(二十六)

【BASH】回顾与知识点梳理 二十六 二十六. 二十一至二十五章知识点总结及练习26.1 总结26.2 模拟26.3 简答题 该系列目录 --> 【BASH】回顾与知识点梳理&#xff08;目录&#xff09; 二十六. 二十一至二十五章知识点总结及练习 26.1 总结 Linux 操作系统上面&#xff0c…

unittest单元测试

当你在编写测试用例时&#xff0c;可以使用Python内置的unittest模块来进行单元测试。下面是一个逐步指南&#xff0c;帮助你理解如何编写和运行基本的单元测试。 导入必要的模块&#xff1a; 首先&#xff0c;你需要导入unittest模块和需要测试的模块&#xff08;例如&#xf…

运维监控学习笔记8

在服务器端&#xff0c;我们添加了nginx-server的主机&#xff1a; 在解决Error问题的过程中&#xff0c;我还通过zabbix_get这个命令进行了测试&#xff0c;发现是没有的&#xff0c;后来确认是在web页面配置的过程中&#xff0c;我输错了密码。 yum install zabbix-getzabbi…

uniapp-原生地图截屏返回base64-进行画板编辑功能

一、场景 vue写uniapp打包安卓包&#xff0c;实现原生地图截屏&#xff08;andirod同事做的&#xff09;-画板编辑功能 实现效果&#xff1a; 二、逻辑步骤简略 1. 由 原生地图nvue部分&#xff0c;回调返回 地图截屏生成的base64 数据&#xff0c; 2. 通过 uni插件市场 im…

《图解HTTP》——HTTP协议详解

一、HTTP协议概述 HTTP是一个属于应用层的面向对象协议&#xff0c;由于其简捷、快速的方式&#xff0c;适用于分布式超媒体信息系统。它于1990年提出&#xff0c;经过几年的使用与发展&#xff0c;得到不断地完善和扩展。目前在WWW中使用的是HTTP/1.0的第六版&#xff0c;HTTP…

muduo 29 异步日志

目录 Muduo双缓冲异步日志模型: 异步日志实现: 为什么要实现非阻塞的日志

SQL 语句解析过程详解

SQL 语句解析过程详解&#xff1a; 1&#xff0e;输入SQL语句 2&#xff0e;词法分析------flex 使用词法分析器&#xff08;由Flex生成&#xff09;将 SQL 语句分解为一个个单词&#xff0c;这些单词被称为“标记“。标记包括关键字、标识符、运算符、分隔符等。 2.1 flex 原…

【CSS 布局】水平垂直方向居中

【CSS 布局】水平垂直方向居中 单行元素 <div class"container"><div class"item"></div> </div>方式一&#xff1a;relative 和 absolute .container {position: relative;height: 400px;border: 1px solid #ccc;.item {posit…

20个互联网用户Python数据分析项目

这篇文章给大家整理了20个互联网用户数据分析的项目。所有收录的项目&#xff0c;进行了严格的筛选&#xff0c;标准有二&#xff1a; 1.有解说性文字&#xff0c;大家能知道每一步在干嘛&#xff0c;新手友好 2.数据集公开&#xff0c;保证大家可以在原文的基础上自行探索 更…