起因
再一次内网穿透的过程中,我发现好多开源软件都是直接要代理服务器的80端口,然后通过配置对外暴露各种域名路由,用户通过访问域名来到我的公网服务器,服务器在将用户请求转发到我的内网客户端,客户端在响应到服务器,服务器在将响应转发给用户。当然,在此之前,本地客户端已经通过Tcp协议和公网服务器建立起了长连接。然后发现大部分软件功能都太全了,并且我的公网服务器的80端口肯定不能给到这些软件,因为我的Caddy正在帮我提供着诸多网站的服务。后续我找到了更好的Frp,同时也对内网穿透的原理和协议产生了好奇心。
前置知识点
HTTP、WebSocket 等协议都是处于 OSI 七层模型的最高层:应用层。而 IP 协议工作在网络层(第3层),TCP、UDP 协议工作在传输层(第4层),HTTP、WebSocket等协议都是对TCP协议的封装,即在TCP的协议上在做一些约定。
长连接 vs 短连接
长连接是指连接建立之后可以持续的发送数据包,而不是发送好了需要的数据包就关闭了。
短连接是指双方要数据交互时,建立一个连接,数据发送完毕,则断开连接,即每次连接只完成一个单元的业务传输,有需要再建立新连接传输数据。
这里的长短连接强调的是应用层对于TCP连接的使用姿势。
**http1.0 vs http1.1 vs webSocket **
Http1.0默认就是典型的短连接,但是在一个运用中多次请求中短链接会频繁的销毁和创建,就会影响性能。因此 http1.1 默认开启了keep-alive,你可以打开浏览器查看请求头,大部分现在请求头中都会有 Connection: keep-alive ,TCP连接就会维持一段时间,这样就尽可能复用建立起来的TCP连接,那么 http1.1 就是长连接了吗?只能说它不是典型的长连接,不像WebSocket那样。也就是说,本身 TCP 作为一种协议,双方一旦建立连接,可以一直相互通讯,除非一方主动关闭,或者失去响应,而当初对应web这种使用场景,才基于TCP设计出了http这种请求响应的模式。那对于web为什么不直接使用TCP为每个用户建立起TCP连接呢?因为对于一台服务器要服务那么多用户,http这种由客户端发送请求,服务端响应完成关闭连接更为合理,所以http协议就是一种应答模式,因此服务端无法主动发送数据就是这种协议就是这样的。对于 WebSocket 来说,它必须依赖 HTTP 协议进行一次握手,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了,因此它可以双向通讯。
tcp vs udp
tcp和udb是在传输层上的一种协议,也就是说它也是通过其它层抽象出来的,而http又是在tcp上抽象的,可以说计算机从晶体管,逻辑运算器一步一步无穷无尽的抽象出来的,把复杂的东西一步一步隐藏再包装。
tcp是一种基于流的协议,需要先建立连接,然后在传输。而udp是一种基于包的协议,在bind 端口后,无需建立连接,是一种即发即收的模式。
思考
梳理上面知识点的时候,我想起了我之前开发的marewood。他是一个前端打包工具,它的大部分使用的是常规的http请求,但是对于任务的打包状态和仓库的使用情况是需要实时通知到当前全部用户的,这个任务,这个仓库我正在打包,其他人需要感知这一行为,因此在使用http的同时,我为每个用户建立起来了一个webSocket 连接,这种混用http和webSocket 在marewood这种场景其实是不太合理的,它增加了程序的复杂度,因为用户通过http请求打包操作,然后在通过webSocket 同步给任何人,一段时间之后,在某个子进程中打包完成,又通过webSocket 通知给大家,庆幸的是,它现在运行良好,其实我可以一开始全程使用webSocket,这样可以更合理的完成任务。
网络编程
因此,基于Tcp协议,我们也可以约定自己的一套应答协议,就像 http 那样。不过http是不支持全双工通信的,我们如何构建自己的Tcp服务和客户端呢?
服务端
在Goalng中,构建一个服务端基本都是Listen + Accept的方式,即监听一个端口(Listen),然后阻塞主程等到客户端连接(Accept),如果有用户连接就开一个 Goroutine 去处理这个连接,再来一个连接就在开一个 Goroutine 去处理。除了Accept是阻塞的,对连接的读写操作也是阻塞的,这里 go 为我们隐藏了底层 Socket 的 I/O 多路复用的复杂性,所以我们只管 Goroutine + 阻塞 I/O 模型 一把梭就行了。
func main(){
listen, err := net.Listen("tcp", ":9090")
if err != nil {
fmt.Println("listen error:", err)
return
}
for {
c, err := listen.Accept()
if err != nil {
fmt.Println("连接错误:", err)
continue
}
go conn(c)
}
}
func conn(c net.Conn) {
defer c.Close()
for {
//读写操作,读也可以设置超时时间
}
}
客户端
conn, err := net.Dial("tcp", "localhost:9090")
conn, err := net.DialTimeout("tcp", "localhost:9090", 5 * time.Second)
Dial和DialTimeout函数也是阻塞的,直到连接成功或者失败。失败的话可能网络不通,网络波动,没有服务等。如果服务端连接数太多也会阻塞着,直到服务端腾出位置来让我们连接,不过可以设置超时,使用 DialTimeout 函数。
同时,如果直接使用Tcp协议的话,因为它的数据是流式传递的,也就是各个机器有一个缓冲区,当数据达到一定情况,就会将字节流传输到对方的缓冲区。这样的话你通过 goalng 的 net.Conn 写入和读取的数据就不是你业务里面的一条数据,每次的读取或者写入只是字节流里面的一部分,直接使用就会出现粘包或者数据不完整,因此一般情况下还需要约定协议,确认每个业务包的大小并需要解包逻辑。
关于Frp
https://github.com/fatedier/frp
在实践的过程中,我还是发现了好用的软件,他就是使用 Golang 编写的 Frp ,服务端和客户端都是跨平台的。使用它也很简单,我们先架设服务器,设置域名,通过域名代理,我们就不用在防火墙设置开放8080端口了。
local.xusenlin.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
设置Frp服务配置 frps.ini
[common]
bind_port = xxxx
vhost_http_port = 8080
这里的 bind_port 就是服务器和客户端通讯的端口了,防火墙记得打开这个端口。
接下来是客户端的配置 frpc.ini
[common]
server_addr = 47.240.xx.xx
server_port = xxxx
[web]
type = http
local_port = 8080
custom_domains = local.xusenlin.com
这样,通过访问 https://local.xusenlin.com 就能穿透到本机的8080端口了,也可以在本机架设NG将8080端口的访问转发到其他内网的应用上,不过这一步没有必要,更好的做法是直接在内网的目标机器上运行 frp 客户端。