请给我一篇 Go 工程实践干货 @ Go中国

mob604756f0bbf4 · · 720 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

前言

在 4 月 27 日举办的 Gopher China 2019 中,国内 Go 语言专家,Bilibili 架构师毛剑进行了题为《Go业务基础库之Error & Context》的演讲,主要探讨两个问题:

  1. 在业务的基础库中,经常需要针对异常进行处理;

  2. 在Go引入context以后,我们如何改造自己的基础库。

本文是他演讲的第二部分——Context 篇,以下为演讲实录。

第二个我们讲上下文,上下文很多地方都在用了,上下文争议非常多。

请给我一篇 Go 工程实践干货 @ Go中国

我们先看背景,我们用上下文到底想解决什么问题?像其他语言,比如说像 Java 可以通过ThreadLocal 很方便取一些东西。Go 经常看到有人搜怎么获取 GoroutineID,或者往里面塞一个什么东西,跨函数调用的时候可以传递等等,这种黑科技尽量少玩,尽量使用 Context 包处理。Context做法其实就是显式传递,我个人比较倾向显式传递比隐示传递好,显式传递我明确传了Context 进去,我就知道通过 Context 暴露方法应该能获取一些什么东西,这是自己的理解(而不是一个神奇的ThreadLocal,还要考虑线程传递,当然使用Context也要注意goroutine传递)。坏处是一污染全污染,所有函数都是首参为Context。

很早以前标准库那个时候没有Context,我们业务基础库改造从无到有(是基于的sub package),觉得两个东西非常重要,第一解决超时传递的问题,第二解决级联取消的问题,还有一些元数据传递的作用。

还有跨进程,我要传给另外一个服务,要识别一些数据,这个我们自己怎么解决呢?

No.1

Context with API

超时&取消

请给我一篇 Go 工程实践干货 @ Go中国

覆盖业务库

第一步覆盖一些业务的基础库。这个是redis的一个Interface,之前是这个样子,标红是新加一个方法,

请给我一篇 Go 工程实践干货 @ Go中国

大家看到有点像net/http,我们是参考它的做法,通过这样一个方法把上下文携带,这样既不会大面积破坏API定义的内容,所以参考的是HTTP的方式做的。覆盖的东西就多了,比如说日志库,上下文要取日志当前的环境(prd还是testing),当前的一些调度路由,APM信息等。Sync不是,这个Sync包和标准Sync有差异,我后面会讲,为什么在这里传递Context,我们Cache,Database,这个不用说了,集成一些中间件需要Tracing就需要传递获取。还有比如说通用的框架,RPC、HTTP一些基础架构的组件,很多是需要传递的。

显示传递大于隐性传递

第二点就是显示传递比隐性传递好,因为明确知道你仓里有Context,我就可以取东西。我非常赞同你们框架比较统一的时候,用一些类似像go generate的方式生成代码。

另外还有一个点特别强调一下,我们有一些框架的代码,有很多同学使用Gin,都会有定义自己一个Context,在自己的Context包了标准库的Context,这种方式我们不建议把这个框架里面的Context传到Service,因为有可能你的service不业务既包括HTTP结果,也会包含gRPC的结构,如果你强依赖是Gin的Context,我后面做一些改装就做不了,所以我们建议框架的Context不下沉,统一使用标准库的Context传递。在框架里面那个Context有一些特殊包装和模板可以内部使用,但是出了这个生命周期不建议再使用他。

请给我一篇 Go 工程实践干货 @ Go中国

Context覆盖这么多东西,我们要干什么呢?

第一就是全基础库覆盖超时,我见到很多语言比如说C++,他们Coroutine实现了异步网络编程的框架,但是一个非常重要的点没有考虑,类似Coroutine这种可以去很方便网络开发,但是他依赖的DB库存、readis库、Memercache库可能并没有覆盖到,这点一定要注意所有使用到的包要覆盖超时传递。

第二就是用了Context之后在root节点可以取消,这个是非常好实现的。

另外刚刚说了传递一些数据。我接下来讲一下我们传递什么东西。我们刚刚说了要控制超时,控制超时一般在哪里做呢?是在流量入口做的,比如说我们HTTP,或者gRPC设置入口超时。在基础库内部会统统记录这一个超时,因为Context有一个deadline还剩多久?通过这种方式你在每一层进入你的库之后当前已经耗时没有多少了,理念就是立马失败。还有一个注意我们利用Context这个传递超时,很多基础库的做法是这样的,开了另外一个Goroutine,那个Goroutine用了Context,你取消的时候把Pending的请求立马返回掉。我们知道有一些系统调用是不方便cancel的。所以要覆盖SetDeadline,在syscall请求前,判断超时传递剩下的quota,重新设置超时,再调用。

你利用Context传递超时,说白了就是一层层包下来,一层层取消,一层层传递,非常方便,这里面核心理念是什么?就是Goroutine的管控,它管控的是生命周期。

请给我一篇 Go 工程实践干货 @ Go中国

另外因为我在很多次分享讲过了,一定要做跨RPC超时传递,所以我们不仅仅是要考虑进程内全链的覆盖,也应该考虑跨服务级的覆盖。这样做比较简单,因为gRPC是天然就传递了这个Metadata,所以我建议大家一定认真看《SRE》这本书。

除了业务框架层面,一定要考虑我们的SaaS基础设施的平台,比如说我们公司用动态CDN加速,从节点回源核心机房,核心机房通过ELB/SLB,最后到达业务API网关,其实上面还有很多层,我曾经见到过很多业务开发同学只关注自己的层面,设置超时传递,上游还有很多基础设施,我希望大家可以考虑一下从边缘CDN节点到我们ELB机房核心的负载均衡的时候,从最上游传递。因为很简单。如果你的边缘已经认为超时了,你的下游还在倒腾处理,其实用户早就收到504,你就浪费资源做无用功,所以要全链路传递,包括我们边缘节点和CDN都要考虑。

讲完了超时,最后讲一下元数据的传递。

元数据传递

请给我一篇 Go 工程实践干货 @ Go中国

框架的拦截器要实现业务逻辑,比如统一拦截鉴权。通过一个token获取到用户的身份,或者用户ID,这个信息放哪里呢?这个东西在内部其实尝试很多方法争论过很多次,第一我觉得不适合直接放在PB里面,对于PB来说把用户ID放在里面,感觉是客户端传一个用户ID要怎么处理,实际上客户端传的是Token,这个放哪里传递呢?我们通过gRPC生成函数原形的时候,以中间件产生的数据不方便放到函数的仓库里面,只能放到context。你怎么知道获取他呢?这个问题我们争论很久了,现在解决方案是这样的。

请给我一篇 Go 工程实践干货 @ Go中国

比如说你提供鉴权服务的人,由鉴权服务的人暴露你的Middleware,使用鉴权的人要引用你的Middleware到HTTP框架里面。到我的业务逻辑层我怎么到上下文取得这个用户ID呢?你要到提供的Middleware的Metadata里去找(即FromContext是库OWNER提供)。这个依赖相对来说比较清晰,我的基础里面永远没有产生这种业务的框架依赖,而是把业务的框架他自己提供,但是由调用者引入进来,同时我知道这个Metadata一定是提供者去取,这个一定要强调一下,否则你代码依赖性和结构性不够清晰。

Caller我们也会通过Context传递。谁调的我的服务,我要分析,我知道整个流量大盘来自谁,但是Caller直接裸传,有一个坏处万一那个同学做恶,伪装成另外一个Caller怎么办?我们自己在逐渐升级做内网Zero Trust。之后在内网启用类似像RootCA这种证书的方式,像gRPC做签名识别对方身份以后,我们内网接口短期不做加通讯加解密,非常核心的接口要做加解密,有一定性能开销,毕竟走RSA还有AES。

另外我们查全链路追踪,这个需要传递。还有路由的信息,比如说Color还有Mirror,Color是染色,像环境里面就是区分环境路由调度(用于多测试环境路由使用)。Mirror就是影子的意思,通过这个标识容易实现全链路压测,很重要就是影子库,压测不能污染线上的数据,通过这个Mirror标识各个中间件传递,我就知道把这个数据应该写到另外一个地方,我们会传递这个标识。

还有我分享过我们gRPC的负载均衡的实现,我们传递gRPC Server一些信息辅助客户端做负载均衡调度的,比如gRPC Server 的CPU 或者 Load。

请给我一篇 Go 工程实践干货 @ Go中国

这里有一个问题。我们看了一下上下文传递无非就是两种,Incoming和outgoing,比如你做了HTTPServer或者gRPCServer,你调我接口肯定传元数据给我,哪一些元数据我需要Carry带到自己的上下文里面,这是Incoming。Outgoing是我作为一个调用者,我要调另外一个人的RPC或者HTTP接口,我要把哪一些东西发出去,这个非常关键。以前我们人肉编码传递非常麻烦。后来我们想了想怎么办?无非就是知道哪一些处理Incoming就是挂载当前上下文,哪一些Outgoing挂载框架上下文。我们会定义这样一个MAP会告诉你哪一些是要发出去,哪一些要进来,挂载上下文的。通过这两个MAP暴露两个方法,在我们内部框架库里面可以自动复循环拷进去或者传递出去比较方便了,不需要硬编码。

请给我一篇 Go 工程实践干货 @ Go中国

这个是我们梳理上下文的一些经验。

请给我一篇 Go 工程实践干货 @ Go中国

还有Goroutine上下文的传递,曾经有同学问,我在写框架代码的时候传递没有问题。但是有时候自己Go一个出去,但是发现原数据丢失了,所以我们内部的Metadata一定提供help方法,通过某种方式引导他,让他跨Goroutine的时候记得传递。比如说可以在Metadata这个包里面提供一个方法,把当前的Context里面的取出来做一个copy,Copy出来以后生成一个新Context,这个Context会用于跨Goroutine,假设要使用的话就把之前的原数据拷进去了,就不容易丢失,不然还得自己手动写这一行代码,容易出错。但是这种方式还是有可能会出错,Context经常容易传错,比如说HTTP框架里有一个Context,本来传递的时候开了一个Goroutine,结果把这个HTTP框架传到这个Goroutine,这个时候HTTP 结束,框架会调Context Cancel,结果go出去的Goroutine出现一个错 context cancel,这种方式确实容易出现,所以通过CI里面的一些脚本,尽可能检测是不是有传错,这个很难处理。

请给我一篇 Go 工程实践干货 @ Go中国请给我一篇 Go 工程实践干货 @ Go中国

第二个方式就是防御编程。

请给我一篇 Go 工程实践干货 @ Go中国

我们提供一些让它启动Goroutine明确告诉他传一个什么参数进去不容易忘,内部发现因为开的Goroutine无非想异步做一个什么事情,所以把这个模型叫Fanout库,我们提供一个这样一个fanout库,告诉你一定要传,我会在内部自动进行一些Metadata的Copy,并且有全链路的Trees信息的一些挂载,生成新的Context再传过去。实际的函数原形不是像Goroutine一样,而是明确传参,尽可能减少Context传错,这样尽可能帮助它减少传错Context的保护。我们内部是这样考虑的。

请给我一篇 Go 工程实践干货 @ Go中国

No.2

Best Pratice

请给我一篇 Go 工程实践干货 @ Go中国

这是网上贴的列表,看一下上下文最佳实践。Background一般是TOP级别,root开启上下文一定是从它开启的,所有项目都是从Background派生的。第二有一些地方传Context你不知道怎么传,你不传,有可能崩溃了,我们很讨厌在传Context还看是不是nil,很烦。如果大家有看过谷歌开源一些代码比如说Cromie,很多C++代码不会看看那个东西是不是传 nil,因为大家编程意识和规范比较好,在最底层判断就行了。为什么提这个要求,不要传nil,不知道传什么的时候就传同步。

另外Context.Value不要什么东西都放进去,参数就是参数不是context value。

另外不要放到结构体里面,放到一个结构体里面,通过结构体访问到他们。你可以包含他,传那个结构体没有关系,因为一定实现接口,但是不要直接传递结构体作为参数。

Error和Context的知识就到这里,其实也偏一些业务开发或者是偏一些技术开发考虑的。

No.3

Conclusion

请给我一篇 Go 工程实践干货 @ Go中国

自己的总结,业务基础库开发没有想象中那么简单,虽然不像高大上的开发,但是当一百个人两百个人用你的基础库的时候,你的任何一个设计不当都会导致别人犯错,所以我一直觉得业务的基础库开发没有想象那么简单。所以我们一定要慎重思考你这个东西会不会导致别人会犯错。这里面有几个点。

第一,不要抽象非常复杂,认为无比可扩展。简单一点相对可靠一点,越简单越不容易出错。

第二,让每一个人正确使用。你设计好的一个函数原型才不容易犯错。另外你设计好了不要假设一定会用得很好,所以要定期看别人怎么用的你的API,比如说你设计的API有人瞎用,跟最初想的完全不一样,这个是要经常回顾看别人咋用的,你作为这个API OWNER要考虑。

第三,就是去大神化编程。因为所有人不是超过大神的很厉害的人物。我希望的基础库随便怎么犯错都不应该很容易挂掉,或者程序出现异常,这个是不好的。

第四,鲁棒性和健壮性是要考虑的,不要老想通过中间件,无比强大都怎么用都不会出问题的中间件解决所有问题。我们基础库也要考虑健壮性。

另外常常思考和进步,CaseStudy里面追溯问题一定要找到代码,为什么会这一行写错,是设计者设计得不好,还是使用者用错了,双向都要考虑,不要怪别人用错,有可能你东西不好用才会用错。

不断质疑自己的设计,不断参考一些优秀好的设计,反思自己基础库哪一些地方做得不好。


Q & A

提问:你好我请问刚刚讲到Context可以在跨服务间传递,应该里面涉及到Context上下文的拷贝,我想请问跨服务间的Context Cancel下游怎么知道是Cancel?

毛剑:这个问题非常有意思,我看谷歌SRE的时候就讲过这个问题,上一个已经失败了,下一个还在跑。第一个通过超时控制的,比如说配一个超时也能快速消耗掉。第二是主动Cancel,Grpc当你客户端调出Cancel是往下游通知把这个方法Cancel的。是一层层通过Grpc传递。曾经因为这个Cancel功能导致C++出现一个BUG堵死了,他是明确告诉你要Cancel掉。

提问:就是上一拨Cancel,也可以有Grpc请求是吗?

毛剑:是的。

提问:您好毛老师,看Ctx封装的时候有一个读传Ctx,FackCtx,是这个意思吗?这个函数在上游调用的时候是不是有问题?比如说实现的时候把两个Ctx变量不一样会用错,如果一样又觉得不太优雅。

毛剑:确实我们之前有同学一团名字,如果你的CTX非常强,一定会报错的。另外其实不建议取一样的名字。

提问:如果起不一样的名字更容易出错,比如说读第一个是C,第二是C,我调用的时候更容易出错?

毛剑:所以说我们内部CI的流程里面有针对这种函数的一些方法去检测,还是防御的手段。这种方式主要是鼓励他不要直接用上面一个人的Context,还是用很低级的手段检测,这个确实没有很好方法,容易写错。

提问:毛老师问一个比较简单的问题,Context时间表一般在Context里面放多少数据?有没有一个参考,几十个,几百个?

毛剑:Context如果直接调V,其实用的是一个新的,像联表关联起来,第一个找不到复循环找第二个第三个,所以你挂越多性能越差。一般会参考看一下Grpc的管理,他通过一个MAP,Context挂载是挂载一个MAP,那个MAP可以放很多个。


有疑问加站长微信联系(非本文作者)

本文来自:51CTO博客

感谢作者:mob604756f0bbf4

查看原文:请给我一篇 Go 工程实践干货 @ Go中国

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

720 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传