兼容 HTTP 最好的办法就是在设计应用层协议之初就考虑到进去。因此,teleport 对应用层协议报文的属性做了如下抽象:
Body
如何实现DIY应用层协议?
应用层协议是指建立在 TCP 协议之上的报文协议。我们希望开发者自己定制该协议,这样更具备灵活性,比如protobuf、thrift等。
首先要做的第一件事是:
抽象出一个 Message 对象,为应用层协议接口提供字节流序列化与反序列化模板。
Step1: 抽象 Message 对象
在 teleport/socket 包中抽象出 Message 结构体(上面已经介绍过了)
Step2: 抽象 Proto 协议接口
提供 Proto 协议接口,对 Message 对象进行序列化与反序列化,从而支持开发者的自定义实现自己的协议格式,其接口声明如下:
解释:
目前框架已经提供三种协议:Raw、JSON、Protobuf。
其中以Raw为示例,展示如下:
如何实现 Body 编码协商?
在实际业务场景中,报文的类型是多种多样的,所以 teleport 使用Codec接口对消息正文(Message Body)进行编解码。
解释:
开发者可以将自定义的新编解码器注入teleport/codec包,从而在整个项目中使用。
框架已经提供的编解码器实现:JSON、Protobuf、Form(urlencoded)、Plain(raw text)
在自由支持各种编解码类型后,我就可以模仿 HTTP 协议头的 Content-Type 实现一下协商功能了。
在 Request/Response 的通信场景下,按以下步骤进行 Body 编码类型协商:
在上述 Step2 中,请求端设置 Message 对象的X-Accept-Body-CodecMeta 元信息的一段代码片段:
其中,tp.WithAcceptBodyCodec是一种修饰函数的用法,这类函数可以实现灵活地配置策略,一些相关定义如下。在 teleport/socket 包中:
在 teleport 包中:
说明:Call 其实类似于 net/http 中的func (c *Client) Do(req *Request) (*Response, error)是根据请求参数 Message 进行请求的。
在该场景中为什么选择使用修饰函数?为什么不直接传入 Message 结构体(先将其字段公开)?
概括一下修饰函数的使用场景:
如何管理连接?
一般常见的 Go 语言 RPC 框架都没有重视对连接的管理,甚至是没有连接管理功能。那么,是不是就说明连接管理功能不重要?可有可无?其实不然,这只是与 RPC 框架的定位有关:
实现远程过程调用,并不强调连接,甚至是刻意屏蔽掉底层连接!
那么,什么场景下,我们需要使用连接管理?
下面我们来了解一下 teleport 是如何实现连接管理的。
Step1:封装 Socket 模块
首先,我们以分层的原则对来自net标准包的net.Conn进行封装得到 Socket 接口。它作为整个框架的底层通信接口,向上层提供应用层消息通信和连接管理的基础功能。
该接口涉及五个组件:
常用接口方法如下:
Step2:封装 Session 模块
Session 对象封装了 Socket 接口 ,并负责整个会话相关的事务(相当于引擎)。如:
Step3:并发 Map 集中管理 Session
Peer 是 teleport 对通信两端的对等抽象,除了 Listener 与 Dialer 固有的角色差异外,两种角色拥有完全一致的API。Peer 就包含有一个并发 Map 用于保存全部 Session。因此,开发者可以通过 Peer 实现:
另外,顺便提一下,teleport是采用非阻塞的通信机制,同时支持同步、异步两种编程方式。这样做有什么好处,或者说阻塞通信与非阻塞通信的区别是什么?
golang 的 socket 是非阻塞式的,也就是说不管是accpet,还是读写都是非阻塞的。但是 golang 本身对 socket 做了一定的处理,让其用起来像阻塞的一样简单。
因此,如果我们当真把它作为阻塞通信机制,通过连接池实现并发通信,是很浪费连接资源的!我们知道,“阻塞通信+连接池”的方式,不仅吞吐量相比较低,而且还有一个无法避免的缺陷:
但是,如果使用非阻塞通信机制,每个请求都不独占连接,而是共享连接。这样:
微服务系统中,强烈建议使用这种非阻塞通信机制!
如何设计灵活的插件
插件会给框架带来灵活性和扩展性,是一个非常重要的模块。那么,如何设计好它?teleport 从三方面考虑:
插件位置(函数)插件位置(函数)
PreNewPeer(*PeerConfig, *PluginContainer) error
PostNewPeer(EarlyPeer) error
PostReg(*Handler) error
PostListen(net.Addr) error
PostDial(PreSession) *Rerror
PostAccept(PreSession) *Rerror
PreWriteCall(WriteCtx) *Rerror
PostWriteCall(WriteCtx) *Rerror
PreWriteReply(WriteCtx) *Rerror
PostWriteReply(WriteCtx) *Rerror
PreWritePush(WriteCtx) *Rerror
PostWritePush(WriteCtx) *Rerror
PreReadHeader(PreCtx) error
PostReadCallHeader(ReadCtx) *Rerror
PreReadCallBody(ReadCtx) *Rerror
PostReadCallBody(ReadCtx) *Rerror
PostReadPushHeader(ReadCtx) *Rerror
PreReadPushBody(ReadCtx) *Rerror
PostReadPushBody(ReadCtx) *Rerror
PostReadReplyHeader(ReadCtx) *Rerror
PreReadReplyBody(ReadCtx) *Rerror
PostReadReplyBody(ReadCtx) *Rerror
PostDisconnect(BaseSession) *Rerror
上面这些函数的入参中,不带有*前缀的都是接口。
其中以Peer、Session、Ctx为后缀的入参(接口类型),涉及到一种非常有趣、有用的 interface 用法——限制方法集。
以Ctx为例:
实践:轻松组装微服务
tp-micro是以 teleport + plugin 的方式扩展而来的微服务。虽然目前还有一些功能未开发,但已有两家公司使用。它在完整继承 teleport 特性的同时,增加如下主要模块:
模块模块模块模块模块模块
服务注册插件
路由发现插件(含负载均衡)
心跳插件
参数绑定与校验插件
安全加密插件
断路器
脚手架工具:由模板生成项目、热编译
网关
灰度
Agent
聊聊高效开发的一些事儿
实现 RPC 开发范式
实现 RPC 范式的好处是代码书写简单、代码结构清晰明了、对开发者友好。
在此只贴出一个简单代码示例go框架,不展开讨论封装细节。
处理错误的姿势
teleport 对于 Handler 的错误返回值,并没有采用 error 接口类型,而是定义了一个 Rerror 结构体:(用法见上面示例代码)
这样设计有几个好处:
可能有人会问:为什么不直接实现Rerror.Error() error?因为我是故意的!原因则涉及到 interface 的一个经典的坑:(*Rerror)(nil) !=(error)(nil)。如果开发者不小心写出下面的代码,就掉坑里了:
推荐:服务端定义一张全局的错误码表,方便于客户端对接以及错误Debug。比如这样的规则:
推荐一种很酷的项目结构
这是 tp-micro 中默认的项目组织结构,它有micro gen命令由模板自动构建。
该项目结构整体分为两部分。
一部分是对外公开的代码,都位于 sdk 目录下,比如 client 远程调用的函数就在这里。
剩余代码都是不对外公开的,属于 server 进程的部分,其中私有包 internal 下是 server 的主体业务逻辑部分。
这样设计的好处是:
脚手架提升开发效率
在 tp-micro 中,提供了一个micro工具,介绍两个最常用的命令:
命令micro run可以自动编译运行指定项目,并在项目代码发生变化时自动进行平滑升级
开源中国征稿开始啦!
开源中国 是目前备受关注、具有强大影响力的开源技术社区,拥有超过 200 万的开源技术精英。我们传播开源的理念,推广开源项目go框架,为 IT 开发者提供一个发现、使用、并交流开源技术的平台。