socket本地多进程通信基本使用方法和示例
socket通信基本函数讲解
目录
6 read() write() send() recv()函数
前言:
进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。这使得一个程序能够在同一时间里处理许多用户的要求。因为即使只有一个用户发出要求,也可能导致一个操作系统中多个进程的运行,进程之间必须互相通话。IPC接口就提供了这种可能性。每个IPC方法均有它自己的优点和局限性,一般,对于单个程序而言使用所有的IPC方法是不常见的。
进程间通信的8种方法:
1、无名管道通信
无名管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
2、高级管道通信
高级管道(popen):将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式。
3、有名管道通信
有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
4、消息队列通信
消息队列( message queue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
5、信号量通信
信号量( semophore ) : 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
6、信号
信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
7、共享内存通信
共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
8、套接字通信
套接字( socket ) : 套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
socket是什么
你也许听很多高手说过,UNIX/Linux 中的一切都是文件!那个家伙说的没错。
为了表示和区分已经打开的文件,UNIX/Linux 会给每个文件分配一个 ID,这个 ID 就是一个整数,被称为文件描述符(File Descriptor)。例如:
- 通常用 0 来表示标准输入文件(stdin),它对应的硬件设备就是键盘;
- 通常用 1 来表示标准输出文件(stdout),它对应的硬件设备就是显示器。
UNIX/Linux 程序在执行任何形式的 I/O 操作时,都是在读取或者写入一个文件描述符。一个文件描述符只是一个和打开的文件相关联的整数,它的背后可能是一个硬盘上的普通文件、FIFO、管道、终端、键盘、显示器,甚至是一个网络连接。
请注意,网络连接也是一个文件,它也有文件描述符!你必须理解这句话。
我们可以通过 socket() 函数来创建一个网络连接,或者说打开一个网络文件,socket() 的返回值就是文件描述符。有了文件描述符,我们就可以使用普通的文件操作函数来传输数据了,例如:
- 用 read() 读取从远程计算机传来的数据;
- 用 write() 向远程计算机写入数据。
你看,只要用 socket() 创建了连接,剩下的就是文件操作了,socket通信原来就是如此简单!
socket基本原理框图
1.服务端首先初始化Socket(),然后和接口进行绑定bind()和监听listen(),然后调用accept()进行阻塞。
2.客户端初始化socket(),然后调用connect()与服务端进行连接,然后客户端负责发送请求,服务端接收并且处理请求。write(),read().在 UNIX/Linux 系统中,为了统一对各种硬件的操作,简化接口,不同的硬件设备也都被看成一个文件。对这些文件的操作,等同于对磁盘上普通文件的操作。
socket基本函数
1 socket() 函数
#include <sys/types.h>
#include <sys/socket.h>
int socket (int af, int type, int protocal );
1) af 为地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。AF 是“Address Family”的简写,INET是“Inetnet”的简写。AF_INET 表示 IPv4 地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。
大家需要记住127.0.0.1
,它是一个特殊IP地址,表示本机地址。
2) type 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)
3) protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
2 bind()函数
socket()函数用来创建套接字,确定套接字的各种属性,然后服务器端要用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。类似地,客户端也要用 connect() 函数建立连接。
#include <sys/types.h>
#include <sys/socket.h>
int bind (int sockfd, const struct sockaddr* my_addr, socklen_t addlen );
//前边的专用地址的指针强制转换成 struct sockaddr* 就行
1)sock 为 socket 文件描述符,
2)addr 为 sockaddr 结构体变量的指针,
3)addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
下面的代码,将创建的套接字与IP地址 127.0.0.1、端口 1234 绑定:
//创建套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
//创建sockaddr_in结构体变量
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); //每个字节都用0填充
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
serv_addr.sin_port = htons(1234); //端口
//将套接字和IP、端口绑定
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
这里我们使用 sockaddr_in 结构体,然后再强制转换为 sockaddr 类型,后边会讲解为什么这样做。
sockaddr_in 结构体
接下来不妨先看一下 sockaddr_in 结构体,它的成员变量如下:
struct sockaddr_in{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
uint16_t sin_port; //16位的端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用,一般用0填充
};
1) sin_family 和 socket() 的第一个参数的含义相同,取值也要保持一致。
2) sin_prot 为端口号。uint16_t 的长度为两个字节,理论上端口号的取值范围为 0~65536,但 0~1023 的端口一般由系统分配给特定的服务程序,例如 Web 服务的端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536 之间分配端口号。
端口号需要用 htons() 函数转换,后面会讲解为什么。
3) sin_addr 是 struct in_addr 结构体类型的变量,下面会详细讲解。
4) sin_zero[8] 是多余的8个字节,没有用,一般使用 memset() 函数填充为 0。上面的代码中,先用 memset() 将结构体的全部字节填充为 0,再给前3个成员赋值,剩下的 sin_zero 自然就是 0 了。
in_addr 结构体
sockaddr_in 的第3个成员是 in_addr 类型的结构体,该结构体只包含一个成员,如下所示:
struct in_addr{
in_addr_t s_addr; //32位的IP地址
};
in_addr_t 在头文件 <netinet/in.h> 中定义,等价于 unsigned long,长度为4个字节。也就是说,s_addr 是一个整数,而IP地址是一个字符串,所以需要 inet_addr() 函数进行转换,例如:
unsigned long ip = inet_addr("127.0.0.1");
printf("%ld\n", ip);
运行结果:
16777343
为什么要搞这么复杂,结构体中嵌套结构体,而不用 sockaddr_in 的一个成员变量来指明IP地址呢?socket() 函数的第一个参数已经指明了地址类型,为什么在 sockaddr_in 结构体中还要再说明一次呢,这不是啰嗦吗?
这些繁琐的细节确实给初学者带来了一定的障碍,我想,这或许是历史原因吧,后面的接口总要兼容前面的代码。各位读者一定要有耐心,暂时不理解没有关系,根据教程中的代码“照猫画虎”即可,时间久了自然会接受。
为什么使用 sockaddr_in 而不使用 sockaddr
bind() 第二个参数的类型为 sockaddr,而代码中却使用 sockaddr_in,然后再强制转换为 sockaddr,这是为什么呢?
sockaddr 结构体的定义如下:
struct sockaddr{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
char sa_data[14]; //IP地址和端口号
};
下图是 sockaddr 与 sockaddr_in 的对比(括号中的数字表示所占用的字节数):
sockaddr 和 sockaddr_in 的长度相同,都是16字节,只是将IP地址和端口号合并到一起,用一个成员 sa_data 表示。要想给 sa_data 赋值,必须同时指明IP地址和端口号,例如”127.0.0.1:80“,遗憾的是,没有相关函数将这个字符串转换成需要的形式,也就很难给 sockaddr 类型的变量赋值,所以使用 sockaddr_in 来代替。这两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。
可以认为,sockaddr 是一种通用的结构体,可以用来保存多种类型的IP地址和端口号,而 sockaddr_in 是专门用来保存 IPv4 地址的结构体。另外还有 sockaddr_in6,用来保存 IPv6 地址,它的定义如下:
struct sockaddr_in6 {
sa_family_t sin6_family; //(2)地址类型,取值为AF_INET6
in_port_t sin6_port; //(2)16位端口号
uint32_t sin6_flowinfo; //(4)IPv6流信息
struct in6_addr sin6_addr; //(4)具体的IPv6地址
uint32_t sin6_scope_id; //(4)接口范围ID
};
正是由于通用结构体 sockaddr 使用不便,才针对不同的地址类型定义了不同的结构体。
3 connect()函数
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen ); //成功返回服务端的文件描述符
各个参数的说明和 bind() 相同,不再赘述。
4 listen() 函数
#include <sys/socket.h>
int listen( int sockfd, int backlog ); //backlog是指完全连接(ESTABLISHED)的客户端的上限
对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态,再调用 accept() 函数,就可以随时响应客户端的请求了。
sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。
所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
请求队列
当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。
缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog 参数指定,但究竟为多少并没有什么标准,可以根据你的需求来定,并发量小的话可以是10或者20。
如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。
当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。
注意:listen() 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept() 函数。
5 accept() 函数
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen ); //成功返回客户端的文件描述符
当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求
它的参数与 listen() 和 connect() 是相同的:sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。
accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字,大家注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。
最后需要说明的是:listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到 accept()。accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。
6 read() write() send() recv()函数
write() read() 通用读写
ssize_t write(int fd, const void *buf, size_t nbytes);
ssize_t read(int fd, void *buf, size_t nbytes);
前面我们说过,两台计算机之间的通信相当于两个套接字之间的通信,在服务器端用 write() 向套接字写入数据,客户端就能收到,然后再使用 read() 从套接字中读取出来,就完成了一次通信。
除此之外还有其他方法进行读写套接字如下:
TCP数据读写
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags );
ssize_t send(int sockfd, void *buf, size_t len, int flags );
UDP读写
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addlen);
ssize_t sendto(int sockfd, void *buf, size_t len, int flags, struct sockaddr *dest_addr, socklen_t *addlen);
socket通用读写
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags );
ssize_t sendmsg(int sockfd, struct msghdr *msg, int flags );
struct msghdr
{
void *msg_name; //socket地址
socklen_t msg_namelen //socket地址长度
struct iovec *msg_iov; //分散的内存块数组
int msg_iovlen; //内存块数量
void *msg_control; //指向辅助数据的起始位置
socklen_t msg_controllen;//辅助数据长度
int msg_flags; //复制函数中的flags
};
struct iovec
{
void *iov_base; //内存起始地址
size_t iov_len; //这块内存长度
};
7 close()函数
#include <unistd.h>
int close(int fd); //通用文件描述符关闭,将fd的引用计数减一
#include <sys/socket.h>
int shutdown(int sockfd, int howto ); //网络专用,howto取值SHUT_RD, SHUT_WR, SHUT_RDWR。
8 字节序转换(hton)
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
htons是将整型变量从主机字节顺序转变成网络字节顺序, 就是整数在地址空间存储方式变为高位字节存放在内存的低地址处。
网络字节顺序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用big-endian排序方式。
htons的功能:将一个无符号短整型的主机数值转换为网络字节顺序,即大尾顺序(big-endian)
htonl,其实是host to network, l 的意思是返回类型是long. 将主机数转换成无符号长整型的网络字节顺序。本函数将一个32位数从主机字节顺序转换成网络字节顺序。
示例代码
本示例是本地多进程间进行通信,完成进程1和进程2分别和service进程建立socket,进而使本地进程1和进程2进行通信
client1 代码
/*************************************************************************
> file Name: client.c
> author: C G
> mail: -
> created Time: 2023年03月01日 星期三 18时19分46秒
> version: 1.0
************************************************************************/
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/time.h>
#include<sys/ioctl.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/time.h>
#include<string.h>
#include"../common.h"
int init(void)
{
int len = 0;
int clientSockfd = 0;
int clientLen= 0;
int result = 0;
struct sockaddr_in clientAddress;
struct sockaddr_in serverAddress;
clientSockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&serverAddress, 0x00, sizeof(serverAddress));
serverAddress.sin_family = AF_INET;
serverAddress.sin_addr.s_addr = inet_addr(SERVICE_STRING_IP);
serverAddress.sin_port = htons(SERVICE_PORT);
len = sizeof(serverAddress);
memset(&clientAddress, 0x00, sizeof(clientAddress));
clientAddress.sin_family = AF_INET;
clientAddress.sin_addr.s_addr = htonl(INADDR_ANY);
clientAddress.sin_port = htons(BT_PORT_ID);
clientLen = sizeof(clientAddress);
bind(clientSockfd, (struct sockaddr*)&clientAddress, clientLen);
result = connect(clientSockfd, (struct sockaddr *)&serverAddress, len);
if(result == -1)
{
printf("connect result is error\n");
exit(1);
}
return clientSockfd;
}
int main()
{
int clientSockfd = 0;
struct Data myData;
struct timeval start;
struct timeval end;
clientSockfd = init();
printf("i am is BT\n");
memset(&myData, 0x00, sizeof(myData));
myData.src = BT_PORT_ID;
myData.der = HMI_PORT_ID;
myData.evt = 1;
write(clientSockfd, &myData, sizeof(myData));
gettimeofday(&start,NULL);
printf("BT write time is %ld.%06ld\n",start.tv_sec, start.tv_usec);
printf("BT write src = %d , der = %d , evt = %d \n",myData.src, myData.der, myData.evt);
memset(&myData, 0x00, sizeof(myData));
read(clientSockfd, &myData, sizeof(myData));
gettimeofday(&end,NULL);
printf("BT read time is %ld.%06ld\n",end.tv_sec, end.tv_usec);
printf("BT read src = %d , der = %d , evt = %d \n",myData.src, myData.der, myData.evt);
close(clientSockfd);
return 0;
}
client 2 代码
/*************************************************************************
> file Name: client.c
> author: C G
> mail: -
> created Time: 2023年03月01日 星期三 18时19分46秒
> version: 1.0
************************************************************************/
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/time.h>
#include<sys/ioctl.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/time.h>
#include<string.h>
#include"../common.h"
int init(void)
{
int len = 0;
int clientSockfd = 0;
int clientLen= 0;
int result = 0;
struct sockaddr_in clientAddress;
struct sockaddr_in serverAddress;
clientSockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&serverAddress, 0x00, sizeof(serverAddress));
serverAddress.sin_family = AF_INET;
serverAddress.sin_addr.s_addr = inet_addr(SERVICE_STRING_IP);
serverAddress.sin_port = htons(SERVICE_PORT);
len = sizeof(serverAddress);
memset(&clientAddress, 0x00, sizeof(clientAddress));
clientAddress.sin_family = AF_INET;
clientAddress.sin_addr.s_addr = htonl(INADDR_ANY);
clientAddress.sin_port = htons(HMI_PORT_ID);
clientLen = sizeof(clientAddress);
bind(clientSockfd, (struct sockaddr*)&clientAddress, clientLen);
result = connect(clientSockfd, (struct sockaddr *)&serverAddress, len);
if(result == -1)
{
printf("connect result is error\n");
exit(1);
}
return clientSockfd;
}
int main()
{
int clientSockfd = 0;
struct Data myData;
struct timeval start;
struct timeval end;
clientSockfd = init();
printf("i am HMI\n");
memset(&myData, 0x00, sizeof(myData));
read(clientSockfd, &myData, sizeof(myData));
gettimeofday(&end,NULL);
printf("HMI read time is %ld.%06ld\n",end.tv_sec, end.tv_usec);
printf("HMI read src = %d , der = %d , evt = %d \n",myData.src, myData.der, myData.evt);
memset(&myData, 0x00, sizeof(myData));
myData.src = HMI_PORT_ID;
myData.der = BT_PORT_ID;
myData.evt = 2;
write(clientSockfd, &myData, sizeof(myData));
gettimeofday(&start,NULL);
printf("HMI write time is %ld.%06ld\n",start.tv_sec, start.tv_usec);
printf("HMI write src = %d , der = %d , evt = %d \n",myData.src, myData.der, myData.evt);
close(clientSockfd);
return 0;
}
service 代码
/*************************************************************************
> file Name: service.c
> author: C G
> mail: -
> created Time: 2023年03月01日 星期三 18时15分49秒
> version: 1.0
************************************************************************/
extern "C"
{
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<sys/time.h>
#include<sys/ioctl.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include"../common.h"
}
#include<map>
int main()
{
std::map<int, int> myMap;
int server_sockfd,client_sockfd;
unsigned int server_len, client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;
int result;
fd_set readfds, testfds;
struct Data myData;
memset(&myData, 0x00, sizeof(myData));
server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(SERVICE_PORT);
server_len = sizeof(server_address);
bind(server_sockfd, (struct sockaddr*)&server_address, server_len);
listen(server_sockfd, LISTEN_MAX);
FD_ZERO(&readfds);
FD_SET(server_sockfd,&readfds);
while(1)
{
char ch;
int fd;
int nread;
ssize_t readSize = 0;
FD_ZERO(&testfds);
testfds = readfds;
result = select(FD_SETSIZE, &testfds, (fd_set*)0, (fd_set*)0, NULL);
if(result < 1)
{
printf("is error\n");
exit(1);
}
for(fd = 0; fd < FD_SETSIZE; fd++)
{
if(FD_ISSET(fd, &testfds))
{
if(fd == server_sockfd)
{
client_len = sizeof(client_address);
client_sockfd = accept(server_sockfd, (struct sockaddr*)&client_address, &client_len);
FD_SET(client_sockfd, &readfds);
printf("adding client on fd %d port %d ID:%s\n", client_sockfd, ntohs(client_address.sin_port),
HMI_PORT_ID==ntohs(client_address.sin_port)?"HMI_PORT_ID":BT_PORT_ID==ntohs(client_address.sin_port)?"BT_PORT_ID":"other_port_ID");
myMap[ntohs(client_address.sin_port)] = client_sockfd;
}
else
{
readSize = read(fd, &myData, sizeof(myData));
if(readSize > 0)
{
struct timeval start;
gettimeofday(&start,NULL);
printf("read time is %ld.%06ld\n",start.tv_sec, start.tv_usec);
printf("read client on fd %d ID %s,src = %d , der = %d => %d, evn = %d \n",fd,
HMI_PORT_ID==myData.der?"HMI_PORT_ID":BT_PORT_ID==ntohs(client_address.sin_port)?"BT_PORT_ID":"other_port_ID",
myData.src, myData.der, myMap[myData.der],myData.evt);
write(myMap[myData.der], &myData, sizeof(myData));
printf("write client on fd %d ID %s,src = %d , der = %d => %d, evn = %d \n\n",fd,
HMI_PORT_ID==myData.der?"HMI_PORT_ID":BT_PORT_ID==ntohs(client_address.sin_port)?"BT_PORT_ID":"other_port_ID",
myData.src, myData.der, myMap[myData.der],myData.evt);
}
else
{
FD_CLR(fd, &readfds);
printf("removing client on fd %d\n",fd);
std::map<int,int>::iterator key = myMap.find(fd);
if(key != myMap.end())
{
myMap.erase(key);
}
nread++;
write(fd, &nread, 1);
}
}
}
}
}
return 0;
}
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)