manout's blog

Something about me

Thrift 是什么

Thrift 是一套包含序列化功能和支持服务通信的 RPC 框架,主要包含三大部分: 代码生成,序列化框架,RPC 框架。
其大致相当于 protoc + protobufer + gprc 的组合,并且支持大量语言,保证常用功能在跨语言间功能一致,是一套全栈式的 RPC 解决方案。整体架构如下

thrift_structure

IDL 与代码生成

Thrift IDL 语法可参考官方文档
其实现细节可见 github

需要注意的地方

Type 中 string 类型说明及正确使用

  • string 按照协议要求需为 UTF-8 编码的字符串
  • binary 可以理解为 list<byte>, 因此字节数组应使用 binary 类型
    ps. 部分语言没有实现 binary 类型,使用 list<byte> 代替
  • Java 和 Python 的生成代码有检查 string 一定为 UTF-8, 但是由于 go 语言 string 底层实现为字节数组因此无法严格保证 UTF-8 编码

字段是否必须

Thrift 在字段是否必须有三种语义: required, optional, 不加显式声明时为 default.

协议要求 default 为写必填 required, 读可选 optional, 但是各种语言实现有差异,建议字段都声明 required 或者 optional

Thrift 序列化

Thrift 是一个支持跨语言的序列化框架,对标 JSON, Protobuf, Avro, 因此一些使用场景为了保持和 RPC 序列化协议的统一或者 RPC 中间数据存储就会使用 Thrift 序列化。
Thrift 序列化协议实现主要是 Binary, Compact, JSON

Thrift 支持数据类型

简单数据类型

bool | byte | i8 | i16 | i32 | i64 | double

复合数据类型

string | binary | map | set | list | struct

特殊数据类型

void | stop

PS

i8binary 有些语言没有实现,建议 i8 使用 byte 或者 i16 替换, binary 使用 list<byte> 替换

TLV 编码

二进制编码常用 TLV 编码实现,TLV 是指由数据的类型 Tag, 数据的长度 Length, 数据的值 Value 组成的结构体,几乎可以描述任意数据类型, TLV 的 Value 也可以是一个 TLV 结构,正因为这种嵌套的特性,可以让我们用来包装协议的实现。
Thrift 中 Binary 和 Compact 编码都是采用的 TLV 编码变种实现,两者不同点在于对整数类型处理方面。

Binary 序列化

简单数据类型为定长编码,包含一个字节类型标识 + 两个字节编号 + 类型对应定长值

这里内部几乎都是使用 Binary 序列化,示例

1
2
3
4
5
struct Test {
1: bool Open,
2: string Name,
3: i32 ID,
}

比如上面的 Test 结构体,Binary 序列化结果是

1
2
3
4
5
test = Test(Open=True, Name="hi", ID=18)
test // 实例序列化 16 进制显示为
02 0001 01
0b 0002 00000002 6869
08 0003 00000012

Compact 序列化

Compact 序列化当时不同于 Binary 点主要在于整数类型使用 zigzag 和 varint 压缩编码实现

varint 编码

步行长无符号整数编码,每个字节只使用 低 7 位,最高一位作为一个标志位 (msb)

  • 下一个 byte 也是该数字的一部分
  • 下一个 byte 不是该字节的一部分

该编码好处的对于小数字采用更少字节,大叔街采用更多字节,但大部分使用都是小数字,则整体看压缩效率明显。
比如 300(i32), Binary 序列化下需要 4 个字节,采用 varint 只需要两个字节

zigzag 编码

varint 解决了无符号编码的问题,假设有符号数也使用 varint 编码,因为负数最高位是 1, 比如 i32 就都会使用 5 个字节了,反而使用更多字节,为了解决有符号负数的问题,先采用 zigzag 编码将有符号数映射到无符号数上,zigzag 具体算法如下

compact 实现

大致逻辑与 Binary 序列化实现一样,就是将 i16, i32, i64 三种类型使用 zigzag + varint 编码实现, string, map, list, set 复合类型长度只采用 varint 编码

RPC 框架

Thrift RPC 整个网络服务一般有五个步骤

thrift_net_service_steps

通讯协议

Thrift 中包含 BinaryProtocolCompactProtocol 通讯协议,分别是前面 BinaryCompact 序列化协议加上 Message 传输的协议部分。

以典型常见的 HTTP 协议为例,主要包含三部分

  • 路由信息(URL)
  • 控制信息(Header)
  • 数据负载(Body)

主要分析下 BinaryProtocol 的实现

BinaryProtocol 协议分为严格模式和非严格模式,严格模式下会带上版本 Version 信息,非严格模式下没有版本信息,默认为严格模式。

其中通讯的消息类型主要有四种

  • CALL
    值为 1, 请求
  • REPLY
    值为 2, 响应
  • EXCEPTION
    值为 3, 异常
  • ONEWAY
    值为 4, 无返回值请求

严格模式

四个字节的版本(含调用类型), 四个字节的消息名称长度,四个字节的流水号,消息负载的值,一个字节的结束标记。

1
2
3
4
5
6
version := uint32(VERSION_1) | uint32(typeID)
WriteI32(int32(version))
WriteString(name)
WriteI32(seqID)
WriteBody(body)
WriteByte(STOP)

非严格模式

四个字节的消息名称长度,一个字节调用类型,四个字节的流水号,消息负载数据的值,一个字节的结束标记。

1
2
3
4
5
WriteString(name)
WriteByte(typeID)
WriteI32(seqID)
WriteBody(body)
WriteByte(STOP)

Transport 实现

Transport 主要分为两类

  • 上层传输通道,负责消息的读写和存储
  • 底层传输通道,负责消息在 client/server 之间传输

上层 Transport 实现

Transport 主要接口有 open, close, read, write, flush, 官方大部分语言都有多种实现,最常使用的是 TBufferedTransportTFramedTransport.

  • TBufferedTransport
    ``TBufferedTransport实现主要是采用了BufferIO` 来存储实现,主要使用场景是在 BIO(阻塞式IO)下使用
  • TFramedTransport
    Protocol 加了 Header(四个字节的消息体大小), 主要使用场景为 NIO(非阻塞IO), 其中 C++ 大部分 Thrift Server 采用 NIO 实现。GO 的 Socket 底层是 NIO, 但是在用户层实现了阻塞,所以可以使用 TBufferedTransport.

如何选择 Transport

  • client
    调用下游使用 Transport, 联系下游,这是服务的元信息
  • server
    从性能和内存使用角度建议使用 TFramedTransport

下层 Transport 实现

最常用的有基于 TCP 和 Unix Socket 两种实现方式,大部分 RPC 服务就是使用 TCP 实现,也有 使用 Unix Socket 实现的场景

Server 实现

由于 Server 实现不考虑跨语言问题,只需要关心实现语言自身特点选用就可以。
一般实现有以下几种

  • TSimpleServer (单进程单线程模式,调试使用))
  • ThreadPoolServer (单进程多线程模式)
  • TProcessPoolServer (多进程单线程模式,Pie 目前采用)
  • 其他基于 NIO 的各种 Server
  • AIO 的实现

select 是 golang 中的一个控制结构,类似于 switch. 每一个 case 都必须为一个通信操作,要么是发送要么是接受。
select 随机选择一个可运行的 case, 如果没有 case 可以运行,便会阻塞,直到有 case 可以运行。一个默认的字句总是可以运行的。

1
2
3
4
5
6
7
8
select {
case communication clause :
statement(s)
case communication clause :
statement(s)
default :
statement(s)
}

以下描述 select 语句的语法

  • 每个 case 都必须是一个通信
  • 所有 channel 表达式都会被求值
  • 所有被发送的表达式都会被求值
  • 如果任意某个通信可以执行,它就会执行;其他就会被忽略
  • 如果有多个 case 都可以运行,select 会随机公平的选出一个执行。其他不会执行。
    否则
    • 如果有 default 子句,则执行该语句
    • 如果没有 default 子句,select 将阻塞,直到某个通信可以执行;channel 或者值不会被重复求值

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import "fmt"

func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
}
}
}

func main() {
c := make(chan int)
quit := make(chan int)

// start a goroutine to print current result
// no buffer in c and quit channel, so this code
// would block when this goroutine try to print
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}

背景

在 Go server 中,每个刚收到的 request 都由对应的 goroutine 处理。Requests handlers 经常开启一个额外的 goroutines 去访问后端,比如数据库或者 RPC 服务。
工作在特定 Request 上的 goroutine 集合通常需要访问 Request 指定的数据,比如终端用户的身份,认证秘钥,以及最后期限。
当一个 Request 取消后或者 time out(过期), 服务于这个 Request 的 goroutine 都需要迅速退出,这样系统可以回收他们正在使用的资源

go 中开发了 context 包来方便地在处理同一个 Request, 跨越 API 边界的 goroutine 的集合中传递 数据,取消信号和最后期限。

Context

Context 接口定义

1
2
3
4
DeadLine() (deadline time.Time, ok book)
Done () <- chan struct {}
Err () error
Value (Key interface{}) interface {}

Deadline

1
`Deadlin()(deadline time.Time, ok bool`

返回当前任务应当结束的时间,同样也是这个 context 应当取消的时间
ok == false 表示没有设置 deadline. 对 Deadline 的连续的成功的调用返回相同的值

Done

1
`Done() <- chan struct{}`

返回一个 closed 的 channel. 当任务已经结束,表示当前的 context 的应当被结束。
Done 可能返回 nil 表示当前 context 没有永远不能取消。对 Done 成功的调用应当返回同样的值。
Done 用语 select 语句。
WithCancelcancel 发生时将 Doneclosed
WithDeadlinedeadline 到了后将 Done 设置为 closed
WithTimeouttimeout 时间计数过期后将 Done 设置为 closed

1
2
3
4
5
6
7
8
9
10
11
12
13
func Stream(ctx context.Context, out chan <- Value) error {
for {
v, err := DoSomething(ctx)
if err != nil {
return err
}
select {
case <- ctx.Done():
return ctx.Err()
case out <- v:
}
}
}

Err

1
`Err() error`

如果 Done 尚未设置为 closed, Err 将返回 nil
如果 Done 已经 closed, Err 将返回 Non-nil error 说明原因
Canceled 表示 context 已经 canceled
DeadlineExceeded 表示 context 的 deadline 已经过期
Err 返回 non-nil 错误后,对 Err 成功的调用都应返回同样的值

Value

1
Value(Key interface{}) interface{}

该接口返回在的 contextkey 相关联的 value, 当没有对应的 key 则返回 value
Value 成功的调用相同的 key 下应返回相同的 value

应仅在跨进程和 API 边界传递 request scope 的数据时使用 context value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package user

import "context"

type User struct {...}

type key int

var userKey key

// returns a new Context that carries value u
func newContext(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}

// returns the User Value stored in ctx, if any
func fromContext(ctx context.Context)(*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}

Derived contexts

context 包支持从已有的 context 中创建新的 context. 这些继承关系为一个树结构,且当父 context 状态变为 canceled 时,继承自该 contextcontext 也会变为 canceled.

所有 Context 树的根节点均为 Background, 且状态永远不会变为 canceled

1
func Background() Context

WithCancel 和 WithTimeout

WithCancelWithTimeout 返回继承自参数的 context, 他会比父 context 更早的变为 canceled.
与到来的 Request 相关联的 context 通常会在 request handler 结束时变为 canceled.
WithCancel 通常用于当使用多个 replicas 时终止多余的 request.
WithCancel 通常用于对后端的 request 设置 deadline

1
2
3
4
5
func WithCancel(parent Context)(ctx Context, cancel CancelFunc)

type CancelFunc func()

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue

WithValue 提供了一个将 request-scopeed 数据与 context 关联的方式

1
func WithValue(parent Context, key interface{}, value interface{}) Context

微服务架构的概念

微服务架构并没有统一的定义,想要理解微服务架构需要与传统的 Web 应用对比。

传统的 WEB 应用结构

传统的 WEB 应用核心划分为: 业务逻辑,适配器以及 API 或者 UI 访问的 WEB 界面。
业务逻辑定义业务流程,业务规则和领域实体。适配器包含数据库访问组件,消息组件以及访问接口等,。

虽然以上功能会分模块开发,但是最终都会打包并部署为单体应用,。例如 Java 应用会打包为 WAR 包部署在 Tomcat 或者 Jetty 上面。

这种单体应用适用于小项目,因为

  • 开发简单直接,便于管理
  • 基本不会重复开发
  • 功能都在本地,没有分布式的管理和调用开销。

缺点在于

  • 开发效率低
  • 代码维护难
  • 部署不灵活
  • 稳定性不高
  • 扩展性不够

因此现在主流的设计会采用微服务架构

微服务架构

微服务架构的思路不是开发一个巨大的单体应用,而是将应用分解为无数小的,互联的微服务。一个微服务完成某个特定的功能,比如下单管理,用户管理等。
每个微服务都有自己的业务逻辑和适配器。一些微服务还会提供 API 接口给其他微服务和应用客户端使用。

可以将每个业务逻辑分解为多个微服务,微服务之间通过 RESET API 通信。通常情况下客户端不能直接访问后台微服务,而是通过 API Gateway 来传递请求。
API Gateway 一般负责路由,负载均衡,缓存,访问控制和鉴权等任务。

微服务架构的优点

  • 解决了复杂性问题
    将单体应用分解为多个微服务,强化了任务模块化的水平,因此微服务架构的开发速度快,更容易理解和维护。
  • 使得不同的服务可以有专注于此服务的团队开发
  • 每个微服务可以单独部署
  • 每个微服务可以独立扩展

微服务架构的挑战

可以大致概括为

  • API Gateway
  • 服务间调用
  • 服务发现
  • 服务容错
  • 服务部署
  • 数据调用

golang 的性能分析分为以下三个方面

  • profile
  • GC&GCDEBUG
  • Trace

Profile

golang 的 profile 分为以下两个方面

  • CPU 分析
    在 runtime 中,每隔很短的时间,记录当前正在运行的协程的栈。持续一段时间,通常是 5-10s。通过分析这段时间记录下来的栈,出现频率比较高的函数则占用 CPU 比较多

  • Mem 分析
    只能分析在堆上申请内存的情况,同 CPU 类似,也是采用采样的方法,每一定此时的内存申请操作会采样一次。通过分析这些采样的记录可以判断哪些语句申请内存较多。

以上 profile 的原理均是基于采样。

defer

语义

defer 关键字表示该语句的函数调用发生在当前的函数体结束时,具体将一个函数调用压入到一个 list 中。
defer 通常用语简化函数返回时的清理动作。

示例,以下代码将读取源文件并且拷贝到另一个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}

dst, err := os.Create(dstName)
if err != nil {
return
}

written, err = io.Copy(dst, src)
dst.Close()
src.Close()
return

事实上这段代码有潜在的 bug, 当 os.Create() 调用失败时,这个函数会返回但是 SouceFile 没有关闭。可以使用另外的手段使得这段代码是安全的,但是使用 defer 语句是最安全和最方便的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func CopyFile(dstName, srcName string) (written int64, err error) {
src, err := os.Open(srcName)
if err != nil {
return
}
defer src.Close()

dst, err := os.Create(dstName)
if err != nil {
return
}
defer dst.Close()

return io.Copy(dst, src)

defer 注意事项

参数的传值

一个 defer 的 function call 的参数的确定是在 defer 语句中确定的。
示例

1
2
3
4
5
func a() {
defer fmt.Println(i)
i++
return
}

以上这段代码会打印 0, fmt.Println 的参数在 defer 时就已确定

多个 defer 的执行顺序

当一个函数体内有多个 defer 语句时,顺序为 LIFO(Last In First Out), 即声明顺序的逆序。

1
2
3
4
5
6
func b() {
for i := 0; i < 4; i++ {
defer fmt.Print(i)
}
}
// prints 3 2 1 0

defer 函数调用可能会影响函数返回值

当一个函数返回时,如果此时调用的 defer function call 中改变了返回值,那么该函数的返回值则会受到影响。

1
2
3
4
func c() (i int) {
defer func() {i++}()
return 1
}

上面这段代码返回值为 2, 其执行过程如下

  • defer statment,function call push into list
  • assign i to 1
  • defer function calls, i = i + 1, i == 2
  • function returns

由此可知 golang 中函数返回分为三步

  • 返回值赋值
  • defer 函数调用
  • 函数返回

这个特性通常用于更改函数返回时的错误值。

panic

panic 是内置函数,可以终止正常的控制流从而开始 panicking, 类似于 C++, Python 中的异常。
如果没有进行处理会导致当前整个 goroutine 的崩溃,进而导致整个程序的崩溃。
panic 可以显式调用,也可以由运行时错误产生,例如越界错误。

recover

recover 是 go 的内置函数,可以重获 panicking gorountine 的控制权。recover 只有在 defered function 中有用。
在正常的执行流中,recover 会返回 nil 并且无任何副作用,如果当前的 gorountine 正在 panicking, 那么 recover 的调用会捕获 panic 的调用值,并且恢复正常的执行。

golang 中常用的几种内置类型如下

  • string
  • Slice
  • Channel
  • map
  • Interface {}

string

golang 中的 string 为值类型,与 python 的 str 大致相同,赋值后无法再修改 string 中的内容,但可以通过方法函数构造新的 string

Slice

在 golang 中的数组类型是值类型,传参时会复制整个数组,但是 Slice 是对底层数组的一段内容的引用。

1
2
data := [...] int {1, 2, 3, 4 ,5 ,6 ,7}
sli := data[1:4:5]

data[1:4:5] 中三个参数分别代表 data 中的 low, high, max
得到的 slice 的即为 {2, 3, 4}, 它的 ,

Channel

channel 用来在多个 rountine 之间无阻塞的发送消息

1
2
3
4
5
ch := make(chan int, 10)
v := 10
ch <- v
v = <- ch
close(ch)

make 的第二参数为该 channel 的长度,如果为 0, 那么这个 channel 就不带 buffer, 如果 , 那么这个 channel 的 buffer 长度为 len
如果 channel 不带 buffer, 那么传送数据不会发生拷贝,读在写之前发生
如果 channel 带 buffer,那么传送数据就会发生拷贝,写在读之前发生
当 channel 为空时,读取操作会阻塞。
写入一个 closedchannel 会导致 panic

channel 支持三个操作

  • read

    1
    v := <- ch
  • write

    1
    ch <- v
  • close

    1
    close(ch)

channel 是并发安全的

map

1
2
3
4
5
6
// nil map, can't use
var m map[string]int
// init with make
m = make(map[string]int)

m := make(map[string]int)

map 实现了键值表,能够实现 时间的查询。

map 支持如下几种操作

  • insert or upadte

    1
    m[key] = element
  • retrieve an element

    1
    ele = m[key]
  • delete

    1
    delete(m, key)
  • test key

    1
    ele, ok := m[key]

Interface

抽象类型,没有具体值,唯一确定的是他包含某种方法

1
2
3
4
type geometry interface {
area() float64
perim() float64
}

在 golang 中实现该接口,只需要在定义类型时实现该接口中的同名方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type rect struct {
width, height float64
}

func (r rect) area() float64 {
return r.width * r.height
}

func (r rect) perim() float64 {
return 2 * r.width + 2 * r.height
}

func measure(g geometry) {
fmt.Println(g)
fmt.Print(g.area())
fmt.Print(g.perim())
}

golang 与其他语言最大不同在于其对并发的支持,由 gorountine 实现,go 关键字声明一个函数放在 gorountie(协程) 中执行,多个 routine 之间通过 channel 实现非阻塞调用。

http 的 Multi get 的一个简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
type RemoteResult struct {
Url string
Result string
}

func RemoteGet(requestUrl string, resultChan chan RemoteResult) {
request := httplib.NewBeegoRequest(requestUrl, "GET")
request.SetTimeout(2 * time.Second, 5 * time.Second)
//request.String()
content, err := request.String()
if err != nil {
content = "" + err.Error()
}
resultChan <- RemoteResult{Url:requestUrl, Result:content}
}
func MultiGet(urls []string) []RemoteResult {
fmt.Println(time.Now())
resultChan := make(chan RemoteResult, len(urls))
defer close(resultChan)
var result []RemoteResult
//fmt.Println(result)
for _, url := range urls {
go RemoteGet(url, resultChan)
}
for i:= 0; i < len(urls); i++ {
res := <-resultChan
result = append(result, res)
}
fmt.Println(time.Now())
return result
}

func main() {
urls := []string{
"http://127.0.0.1/test.php?i=13",
"http://127.0.0.1/test.php?i=14",
"http://127.0.0.1/test.php?i=15",
"http://127.0.0.1/test.php?i=16",
"http://127.0.0.1/test.php?i=17",
"http://127.0.0.1/test.php?i=18",
"http://127.0.0.1/test.php?i=19",
"http://127.0.0.1/test.php?i=20"
}
content := MultiGet(urls)
fmt.Println(content)
}

分析

给出多个 url 列表,使用 goroutine 实现并发获取网页内容。

获取 url content 方法为 Remote Get, 在 MultiGet 中使用 go 关键字调用,每当调用一次就会产生一个新的 goroutine, 代码运行总的时间小于其串行时间,最后的 master 从 resultchan 通道中获取数据,最后打印。

golang 中的指针使用和声明方法与 C/C++ 大致相同。
示例

1
2
3
4
5
var p *int
i := 42
p = &i
*p = 43
*p += 1

与 C/C++ 不同的是 golang 中的指针没有指针运算。

当指针指向一个结构体时,对其成员的引用无需显式写出 *p

1
2
3
4
5
6
7
8
9
10
11
12
type Point struct {
x int
y int
}

func main() {
v := Point{1, 2}
var p *Point
p = &v
p.x = 2
p.y = 3
}
0%