世界快播:IOC-Golang 的 AOP 原理与应用
2022-07-01 09:49:26来源:阿里开发者
作者 | 李志信(冀锋)
AOP 与 IOC 的关系AOP (面向切面编程)是一种编程设计思想,旨在通过拦截业务过程的切面,实现特定模块化的能力,降低业务逻辑之间的耦合度。这一思路在众多知名项目中都有实践。例如 Spring 的切点 PointCut 、 gRPC的拦截器 Interceptor 、Dubbo 的过滤器 Filter。AOP 只是一种概念,这种概念被应用在不同的场景下,产生了不同的实现。
我们首先讨论比较具体的 RPC 场景,以 gRPC 为例。
【资料图】
图片摘自 grpc.io
针对一次 RPC 过程,gRPC 提供了可供用户扩展的 Interceptor 接口,方便开发者写入与业务相关的拦截逻辑。例如引入鉴权、服务发现、可观测等能力,在 gRPC 生态中存在很多基于 Interceptor 的扩展实现,可参考go-grpc-middleware[1]。这些扩展实现归属于 gRPC 生态,限定于 Client 和 Server 两侧的概念,限定于 RPC 场景。
我们将具象的场景抽象化,参考 Spring 的做法。
Spring 具备强大的依赖注入能力,在此基础之上,提供了适配与业务对象方法的 AOP 能力,可以通过定义切点,将拦截器封装在业务函数外部。这些 “切面”、“切点” 的概念,都是限定于 Spring 框架内,由其依赖注入(也就是 IOC)能力所管理。
我想表达的观点是,AOP 的概念需要结合具体场景落地,必须受到来自所集成生态的约束。我认为单独提 AOP 的概念,是不具备开发友好性和生产意义的,例如我可以按照面向过程编程的思路,写一连串的函数调用,也可以说这是实现了 AOP,但其不具备可扩展性、可迁移性、更不具备通用性。这份约束是必要的,可强可弱,例如 Spring 生态的 AOP,较弱的约束具备较大的可扩展性,但实现起来相对复杂,发者需要学习其生态的众多概念与 API,再若 Dubbo 、gRPC 生态的适配于 RPC 场景的 AOP,开发者只需要实现接口并以单一的 API 注入即可,其能力相对局限。
上述 “约束” 在实际开发场景可以具象为依赖注入,也就是 IOC。开发者需要使用的对象由生态所纳管、封装,无论是 Dubbo 的 Invoker、还是 Spring 的 Bean,IOC 过程为 AOP 的实践提供了约束借口,提供了模型,提供了落地价值。
Go 生态与 AOPAOP 概念与语言无关,虽然我赞成使用 AOP 的最佳实践方案需要 Java 语言,但我不认为 AOP 是 Java 语言的专属。在我所熟悉的 Go 生态中,依然有较多基于 AOP 思路的优秀项目,这些项目的共性,也如我上一节所阐述的,都是结合特定生态,解决特定业务场景问题,其中解决问题的广度,取决于其 IOC 生态的约束力。IOC 是基石,AOP 是 IOC 生态的衍生物,一个不提供 AOP 的 IOC 生态可以做的很干净很清爽,而一个提供 AOP 能力的 IOC 生态,可以做的很包容很强大。
上个月我开源了 IOC-golang [2]服务框架,专注于解决 Go 应用开发过程中的依赖注入问题。很多开发者把这个框架和 Google 开源的 wire [3]框架做比较,认为没有 wire 清爽好用,这个问题的本质是两个生态的设计初衷不同。wire 注重 IOC 而非 AOP,因此开发者可以通过学习一些简单的概念和 API,使用脚手架和代码生成能力,快速实现依赖注入,开发体验很好。IOC-golang 注重基于 IOC 的 AOP 能力,并拥抱这一层的可扩展性,把 AOP 能力看作这一框架和其他 IOC 框架的差异点和价值点。
相比于解决具体问题的 SDK,我们可以把依赖注入框架的 IOC 能力看作“弱约束的IOC场景”,通过两个框架差异点比较,抛出两个核心的问题:
Go 生态在 “弱约束 IOC 的场景” 需不需要 AOP?
GO 生态在 “弱约束 IOC 的场景” 的 AOP 可以用来做什么?
我的观点是:Go 生态一定是需要 AOP 的,即使在“弱约束 IOC 场景”,依然可以使用 AOP 来做一些业务无关的事情,比如增强应用的运维可观测能力。由于语言特性,Go 生态的 AOP 不能和 Java 划等号,Go 不支持注解,限制了开发者使用编写业务语义 AOP 层的便利性,所以我认为 Go 的 AOP 并不适合处理业务逻辑,即使强行实现出来,也是反直觉的。我更接受把运维可观测能力赋予 Go 生态的 AOP 层,而开发者对于 AOP 是无感知的。
例如,对于任何接口的实现结构,都可以使用 IOC-golang 框架封装运维 AOP 层,从而让一个应用程序的所有对象都具备可观测能力。除此之外,我们也可以结合 RPC 场景、服务治理场景、故障注入场景,产生出更多 “运维” 领域的扩展思路。
IOC-golang 的 AOP 原理使用 Go 语言实现方法代理的思路有二,分别为通过反射实现接口代理,和基于 Monkey 补丁的函数指针交换。后者不依赖接口,可以针对任何结构的方法封装函数代理,需要侵入底层汇编代码,关闭编译优化,对于 CPU 架构有要求,并且在处理并发请求时会显著削弱性能。
前者的生产意义较大,依赖接口,也是本节所讨论的重点。
3.1 IOC-golang 的接口注入在本框架开源的第一篇文章中有提到,IOC-golang 在依赖注入的过程具备两个视角,结构提供者和结构使用者。框架接受来自结构提供者定义的结构,并按照结构使用者的要求把结构提供出来。结构提供者只需关注结构本体,无需关注结构实现了哪些接口。而结构使用者需要关心结构的注入和使用方式:是注入至接口?注入至指针?是通过 API 获取?还是通过标签注入获取?
通过标签注入依赖对象// +ioc:autowire=true// +ioc:autowire:type=singletontype App struct { // 将实现注入至结构体指针 ServiceStruct *ServiceStruct `singleton:""` // 将实现注入至接口 ServiceImpl Service `singleton:"main.ServiceImpl1"`}
App 的 ServiceStruct 字段是具体结构的指针,字段本身已经可以定位期望被注入的结构,因此不需要在标签中给定期望被注入的结构名。对于这种注入到结构体指针的字段,无法通过注入接口代理的方式提供 AOP 能力,只能通过上文提到的 monkey 补丁方案,这种方式不被推荐。
App 的 ServiceImpl 字段是一个名为 Service 的接口,期望注入的结构指针是 main.ServiceImpl。本质上是一个从结构到接口的断言逻辑,虽然框架可以进行接口实现的校验,但仍需要结构使用者保证注入的接口实现了该方法。对于这种注入到接口的方式,IOC-golang 框架自动为 main.ServiceImpl 结构创建代理,并将代理结构注入在 ServiceImpl 字段,因此这一接口字段具备了 AOP 能力。
因此,ioc 更建议开发者面向接口编程,而不是直接依赖具体结构,除了 AOP 能力之外,面向接口编程也会提高 go 代码的可读性、单元测试能力、模块解耦合程度等。
通过 API 的方式获取对象IOC-golang 框架的开发者可以通过 API 的方式获取结构指针,通过调用自动装载模型(例如singleton)的 GetImpl 方法,可以获取结构指针。
func GetServiceStructSingleton() (*ServiceStruct, error) { i, err := singleton.GetImpl("main.ServiceStruct", nil) if err != nil { return nil, err } impl := i.(*ServiceStruct) return impl, nil}
使用 IOC-golang 框架的开发者更推荐通过API 的方式获取接口对象,通过调用自动装载模型(例如singleton)的 GetImplWithProxy 方法,可以获取代理结构,该结构可被断言为一个接口供使用。这个接口并非结构提供者手动创建,而是由 iocli 自动生成的“结构专属接口”,在下文中将予以解释。
func GetServiceStructIOCInterfaceSingleton() (ServiceStructIOCInterface, error) { i, err := singleton.GetImplWithProxy("main.ServiceStruct", nil) if err != nil { return nil, err } impl := i.(ServiceStructIOCInterface) return impl, nil}
这两种通过 API 获取对象的方式可以由 iocli 工具自动生成,注意,这些代码的作用都是方便开发者调用 API ,减少代码量,而 ioc 自动装载的逻辑内核并不是由工具生成的,这是与 wire 提供的依赖注入实现思路的不同点之一,也是很多开发者误解的一点。
IOC-golang 的结构专属接口。通过上面的介绍,我们知道 IOC-golang 框架推荐的 AOP 注入方式是强依赖接口的。但要求开发者为自己的全部结构,都手写一个与之匹配的接口出来,这会耗费大量的时间。因此 iocli 工具可以自动生成结构专属接口,减轻开发人员的代码编写量。
例如一个名为 ServiceImpl 的结构,其包含 GetHelloString 方法。
// +ioc:autowire=true// +ioc:autowire:type=singletontype ServiceImpl struct {}func (s *ServiceImpl) GetHelloString(name string) string { return fmt.Sprintf("This is ServiceImpl1, hello %s", name)}
当执行 iocli gen 命令后, 会在当前目录生成一份代码zz_generated.ioc.go 其中包含该结构的“专属接口”:
type ServiceImplIOCInterface interface { GetHelloString(name string) string}
专属接口的命名为 $(结构名)IOCInterface,专属接口包含了结构的全部方法。专属接口的作用有二:
减轻开发者工作量,方便直接通过 API 的方式 Get 到代理结构,方便直接作为字段注入。结构专属接口可以直接定位结构 ID,因此在注入专属接口的时候,标签无需显式指定结构类型:// +ioc:autowire=true// +ioc:autowire:type=singletontype App struct { // 注入 ServiceImpl 结构专属接口,无需在标签中指定结构ID ServiceOwnInterface ServiceImplIOCInterface `singleton:""`}
因此,随便找一个现有的 go 工程,其中使用结构指针的位置,我们推荐替换成结构专属接口,框架默认注入代理;对于其中已经使用了接口的字段,我们推荐直接通过标签注入结构,也是由框架默认注入代理。按照这种模式开发的工程,其全部对象都将具备运维能力。
3.2 代理的生成与注入上一小节所提到的“注入至接口”的对象,都被被框架默认封装了代理,具备运维能力,并提到了 iocli 会为所有结构产生“专属接口”。在本节中,将解释框架如何封装代理层,如何注入至接口的。
代理结构的代码生成与注册在前文提到生成的 zz.generated.ioc.go 代码中包含结构专属接口,同样,其中也包含结构代理的定义。还是以上文中提到的 ServiceImpl 结构为例,它生成的代理结构如下:
type serviceImpl1_ struct { GetHelloString_ func(name string) string}func (s *serviceImpl1_) GetHelloString(name string) string { return s.GetHelloString_(name)}
代理结构命名为小写字母开头的 $(结构名)_,其实现了“结构专属接口” 的全部方法,并将所有方法调用代理至 $(方法名)_ 的方法字段,该方法字段会被框架以反射的方式实现。
与结构代码一样,代理结构也会在这个生成的文件中注册到框架:
func init(){ normal.RegisterStructDescriptor(&autowire.StructDescriptor{ Factory: func() interface{} { return &serviceImpl1_{} // 注册代理结构 }, })}代理对象的注入
上述内容描述了代理结构的定义和注册过程。当用户期望获取封装了AOP层的代理对象,将首先加载真实对象,然后尝试加载代理对象,最终通过反射实例化代理对象,注入接口,从而赋予接口运维能力。该过程可由下图展示:
IOC-golang 基于 AOP 的应用理解了上文中提到的实现思路,我们可以认为,使用 IOC-golang 框架开发的应用程序中,从框架注入、获取的所有接口对象都是具备运维能力的。我们可以基于 AOP 的思路,扩展出我们期望的能力。我们提供了一个简易的电商系统 demo shopping-system[4],展示了在分布式场景下 IOC-golang 基于 AOP 的可视化能力。感兴趣的开发者可以参考 README,在自己的集群里运行这个系统,感受其运维能力底座。
4.1 方法、参数可观测查看应用接口和方法% iocli listgithub.com/alibaba/ioc-golang/extension/autowire/rpc/protocol/protocol_impl.IOCProtocol[Invoke Export]github.com/ioc-golang/shopping-system/internal/auth.Authenticator[Check]github.com/ioc-golang/shopping-system/pkg/service/festival/api.serviceIOCRPCClient[ListCards ListCachedCards]监听调用参数
通过 iocli watch命令, 我们可以监听鉴权接口的 Check 方法的调用:
iocli watch github.com/ioc-golang/shopping-system/internal/auth.Authenticator Check发起针对入口的调用
curl -i -X GET "localhost:8080/festival/listCards?user_id=1&num=10"
可查看到被监听方法的调用参数和返回值,user id 为1。
% iocli watch github.com/ioc-golang/shopping-system/internal/auth.Authenticator Check========== On Call ==========github.com/ioc-golang/shopping-system/internal/auth.Authenticator.Check()Param 1: (int64) 1========== On Response ==========github.com/ioc-golang/shopping-system/internal/auth.Authenticator.Check()Response 1: (bool) true4.2 全链路追踪
基于 IOC-golang 的 AOP 层,可以提供用户无感知、业务无侵入的分布式场景下全链路追踪能力。即一个由本框架开发的系统,可以以任何一个接口方法为入口,采集到方法粒度的跨进程调用全链路。
基于 shopping-system 的全链路耗时信息,可以排查到名为 festival 进程的 gorm.First() 方法是系统的瓶颈。
这个能力的实现包括两部分,分别是进程内的方法粒度链路追踪,和进程之间的 RPC 调用链路追踪。IOC 旨在打造开发者开箱即用的应用开发生态组件,这些内置的组件与框架提供的 RPC 能力都具备了运维能力。
基于 AOP 的进程内链路追踪IOC-golang 提供的链路追踪能力的进程内实现,是基于 AOP 层做的,为了做到业务无感知,我们并没有通过 context 上下文的方式去标识调用链路,而是通过 go routine id 进行标识。通过 go runtime 调用栈,来记录当前调用相对入口函数的深度。
基于 IOC 原生 RPC 的进程间链路追踪IOC-golang 提供的原生 RPC 能力,无需定义 IDL文件,只需要为服务提供者标注 // +ioc:autowire:type=rpc ,即可生成相关注册代码和客户端调用存根,启动时暴露接口。客户端只需要引入这一接口的客户端存根,即可发起调用。这一原生 RPC 能力基于 json 序列化和 http 传输协议,方便承载链路追踪 id。
展望IOC-golang 开源至今已经突破 700 star,其热度的增长超出了我的想象,也希望这个项目能带来更大的开源价值与生产价值,欢迎越来越多的开发者参与到这个项目的讨论和建设中。
参考链接:[1]https://github.com/grpc-ecosystem/go-grpc-middleware
[2]https://github.com/alibaba/ioc-golang
[3]https://github.com/google/wire
[4]https://github.com/ioc-golang/shopping-system