文章首发:聊聊第一个开源项目 - CProxy 作者:会玩code
最近在学C++,想写个项目练练手。对网络比较感兴趣,之前使用过ngrok(GO版本的内网穿透项目),看了部分源码,想把自己的一些优化想法用C++实现一下,便有了这个项目。
CProxy是一个反向代理,用户可在自己内网环境中启动一个业务服务,并在同一网络下启动CProxyClient,用于向CProxyServer注册服务。CProxyClient和CProxyServer之间会创建一个隧道,外网可以通过访问CProxyServer,数据转发到CProxyClient,从而被业务服务接收到。实现内网服务被外网访问。
https://github.com/lzs123/CProxy.git
bash build.sh // 启动服务端 {ProjectDir}/build/server/Server --proxy_port=8090 --work_thread_nums=4 (另一个终端) // 启动客户端 {ProjectDir}/build/client/Client --local_server=127.0.0.1:7777 --cproxy_server=127.0.0.1:8080
PublicClient先将请求打到CProxyServer,CProxyServer识别请求是属于哪个CProxyClient,然后将数据转发到CProxyClient,CProxyClient再识别请求是属于哪个LocalServer的,将请求再转发到LocalServer,完成数据的转发。
先介绍CProxyServer端的两个概念:
在CProxyClient端,也会维护一个TunnelMap,每个Tunnel对应一个LocalServer服务,只不过Client端的Tunnel与Server端的Tunnel存储的内容略有差异
为了避免频繁创建销毁proxy连接,在完成数据转发后,会将proxyConn放到空闲队列中,等待下次使用。
proxy_conn有两种模式 - 数据传输模式和空闲模式。在数据传输模式中,proxy_conn不会去读取解析缓冲区中的数据,只会把数据通过pipe管道转发到local_conn; 空闲模式时,会读取并解析缓冲区中的数据,此时的数据是一些控制信息,用于调整proxy_conn本身。
当有新publicClient连接时,会先从空闲列表中获取可用的proxy_conn,此时proxy_conn处于空闲模式,CProxyServer端会通过proxy_conn向CProxyClient端发送StartProxyConnReqMsg,
CLient端收到后,会为这个proxy_conn绑定一个local_conn, 并将工作模式置为数据传输模式。之后数据在这对proxy_conn上进行转发。
close和shutdown的区别
- close
int close(int sockfd)在不考虑so_linger的情况下,close会关闭两个方向的数据流。
- 读方向上,内核会将套接字设置为不可读,任何读操作都会返回异常;
- 输出方向上,内核会尝试将发送缓冲区的数据发送给对端,之后发送fin包结束连接,这个过程中,往套接字写入数据都会返回异常。
- 若对端还发送数据过来,会返回一个rst报文。
注意:套接字会维护一个计数,当有一个进程持有,计数加一,close调用时会检查计数,只有当计数为0时,才会关闭连接,否则,只是将套接字的计数减一。
2. shutdownint shutdown(int sockfd, int howto)shutdown显得更加优雅,能控制只关闭连接的一个方向
howto = 0
关闭连接的读方向,对该套接字进行读操作直接返回EOF;将接收缓冲区中的数据丢弃,之后再有数据到达,会对数据进行ACK,然后悄悄丢弃。howto = 1
关闭连接的写方向,会将发送缓冲区上的数据发送出去,然后发送fin包;应用程序对该套接字的写入操作会返回异常(shutdown不会检查套接字的计数情况,会直接关闭连接)howto = 2
0+1各操作一遍,关闭连接的两个方向。
项目使用shutdown去处理数据连接的断开,当CProxyServer收到publicClient的fin包(CProxyClient收到LocalServer的fin包)后,通过ctlConn通知对端,
对端收到后,调用shutdown(local_conn_fd/public_conn_fd, 2)关闭写方向。等收到另一个方向的fin包后,将proxyConn置为空闲模式,并放回空闲队列中。
在处理链接断开和复用代理链接这块遇到的坑比较多
数据在Server和Client都需进行转发,将数据从一个连接的接收缓冲区转发到另一个连接的发送缓冲区。如果使用write/read系统调用,整个流程如下图
数据先从内核空间复制到用户空间,之后再调用write系统调用将数据复制到内核空间。每次系统调用,都需要切换CPU上下文,而且,两次拷贝都需要CPU去执行(CPU copy),所以,大量的拷贝操作,会成为整个服务的性能瓶颈。
在CProxy中,使用splice的零拷贝方案,数据直接从内核空间的Source Socket Buffer转移到Dest Socket Buffer,不需要任何CPU copy。
splice通过pipe管道“传递”数据,基本原理是通过pipe管道修改source socket buffer和dest socket buffer的物理内存页
splice并不涉及数据的实际复制,只是修改了socket buffer的物理内存页指针。
CProxyClient和CProxyServer均采用多线程reactor模型,利用线程池提高并发度。并使用epoll作为IO多路复用的实现方式。每个线程都有一个事件循环(One loop per thread)。线程分多类,各自处理不同的连接读写。
为了避免业务连接处理影响到Client和Server之间控制信息的传递。我们将业务数据处理与控制数据处理解耦。在Server端中设置了三种线程:
client端比较简单,只有两种线程:
在使用ab压测时,在完成了几百个转发后,就卡住了,通过tcpdump抓包发现客户端使用A端口连接,但服务端accept后打印的客户端端口是B。
数据流在【publicClient->CProxyServer->CProxyClient->LocalServer】是正常的;
但回包方向【LocalServer->CProxyClient->CProxyServer-❌->publicClient】,目前还没有找到分析方向。。。
喜欢本文的朋友,欢迎关注公众号「会玩code」,专注大白话分享实用技术