OOP 思想在 TCC/APIX/GORM 源码中的应用

网站建设4年前发布
34 0 0

作者 | 张瀚珑
,OOP,面向对象程序设计(Object Oriented Programming,OOP)是一种计算机编程架构。OOP 的一条基本原则是计算机程序由单个能够起到子程序作用的单元或对象组合而成。OOP 达到了软件工程的三个主要目标:重用性、灵活性和扩展性。面向对象编程的三大特点:封装性、继承性和多态性。,TCC,动态配置中心 TCC(ToutiaoConfigCenter)是提供给业务方的一套平台+SDK 的配置管理解决方案,提供的功能有权限管理、配置管理、版本管理、灰度发布、多地区多环境支持等。与百度开源的“百度分布式配置中心 BRCC”功能类似。,APIX,Golang 实现的 web 框架,可参考开源项目 Gin。,GORM,Golang 编写的热门数据库 ORM 框架。,大力智能学习灯于 2019 年 10 月份上线,截止 2021 年底,台灯出货量已超过 100w 台,完成了从 0 到 1 的探索。在成立之初,很多方向的产品为了尽早拿到用户反馈,要求快速迭代,研发在代码实现上相对快糙猛,早期阶段这无可厚非,但慢慢地,自习室、系统工具、知识宇宙等应用已经变成灯上核心基建,如果还按之前的野蛮生长的方式将会为台灯的成长埋下隐患。,在这样的背景下,大力智能服务端推动 OOP 技术专项的落地,希望能够:提升团队成员自身的编码水平;统一团队内部编程风格;支撑业务快速迭代。,TCC、APIX、GORM 都是日常项目中经常会依赖到的外部包,本文从这些项目的源码出发,在学习的过程中,解读良好的代码设计在其中的应用,希望能帮忙大家更好的理解和应用 OOP 思想,写出更优秀的代码。,一个类只负责一个职责(功能模块)。,一个类、方法或模块的扩展性要保持开放,可扩展但不影响源代码(封闭式更改),子类可以替换父类,并且不会导致程序错误。,一个类对另一个类的依赖应该建立在最小的接口上。,依赖倒置原则(DIP),高层次的模块不应该依赖于低层次的模块,它们应该依赖于抽象。,解决问题:在设计一个函数时,当存在配置参数较多,同时参数可选时,函数式选项模式是一个很好的选择,它既有为不熟悉的调用者准备好的默认配置,还有为需要定制的调用者提供自由修改配置的能力,且支持未来灵活扩展属性。,TCC 在创建BConfigClient对象时使用了该模式。BConfigClient是用于发送 http 请求获取后端服务中 key 对应的 value 值,其中getoptions结构体是 BConfigClient 的配置类,包含请求的 cluster、addr、auth 等信息,小写开头,属于内部结构体,不允许外部直接创建和修改,但同时对外提供了GetOption的方法去修改getoptions中的属性,其中WithCluster、WithAddr、WithAuth是快捷生成GetOption的函数。,这样的方式很好地控制了哪些属性能被外部修改,哪些是不行的。当getoptions需要增加新属性时,给定一个默认值,对应增加一个新GetOption方法即可,对于历史调用方来说无感,能向前兼容式的升级,符合 OOP 中的对修改关闭,对扩展开放的开闭设计原则。,NewBConfigClient方法接受一个可变长度的GetOption,意味着调用者可以不用传任何参数,开箱即用,也可以根据自己的需要灵活添加。函数内部首先初始化一个默认配置,然后循环执行GetOption方法,将用户定义的操作赋值给默认配置。,解决问题:当已有类功能不够便捷时,通过组合的方式实现对已有类的功能扩展,实现了对已有代码的黑盒复用。,TCC 使用了装饰模式扩展了原来已有的ClientV2的能力。,在下面的DemotionClient结构体中组合了ClientV2的引用,对外提供了GetInt和GetBool两个方法,包掉了对原始 string 类型的转换,对外提供了更为便捷的方法。,由于 Golang 语言对嵌入类型的支持,DemotionClient在扩展能力的同时,ClientV2的原本方法也能正常调用,这样语法糖的设计让组合操作达到了继承的效果,且符合 OOP 中替换原则。,与 Java 语言对比,如下面的例子,类 A 和类 B 实现了IHi的接口,类 C 组合了接口IHi, 如果需要暴露IHi的方法,则类 C 需要添加一个代理方法,这样 java 语言的组合在代码量上会多于继承方式,而 Golang 中无需额外代码即可提供支持。,解决问题:将对象复杂的构造逻辑隐藏在内部,调用者不用关心细节,同时集中变化。,TCC 创建LogCounnter时使用了工厂模式,该类作用是根据错误日志出现的频率判断是否需要打印日志,如果在指定的时间里,错误日志的触发超过指定次数,则需要记录日志。,NewLogCounter方法通过入参 LogMode 枚举类型即可生成不同规格配置的LogCounter,可以无需再去理解 TriggerLogCount、TriggerLogDuration、Enable 的含义。,识别变化隔离变化,简单工厂是一个显而易见的实现方式。它符合了 DRY 原则(Don't Repeat Yourself!),创建逻辑存放在单一的位置,即使它变化,也只需要修改一处就可以了。DRY 很简单,但却是确保我们代码容易维护和复用的关键。DRY 原则同时还提醒我们:对系统职能进行良好的分割,职责清晰的界限一定程度上保证了代码的单一性。[引用自 https://blog.51cto.com/weijie/82767],解决问题:使用多个简单的对象一步一步构建成一个复杂的对象。,APIX 在创建请求的匹配函数Matcher时使用了建造者模式。,APIX 中提供了指定对哪些 request 生效的中间件,定义和使用方式如下,CondHandlersChain结构体中定义了匹配函数Matcher和命中后执行的处理函数HandlersChain。,以“对路径前缀为`/wechat` 的请求开启微信认证中间件”为例子,Matcher 函数不用开发者从头实现一个,只需要初始化 SimpleMatcherBuilder 对象,设置请求前缀后,直接 Build 出来即可,它将复杂的匹配逻辑隐藏在内部,非常好用。,SimpleMatcherBuilder是一个建造者,它实现了MatcherBuilder接口,该类支持 method、pathPrefix 和 paths 三种匹配方式,业务方通过Method()、PrefixPath()、FullPath()三个方法的组合调用即可构造出期望的匹配函数。,除此之外,ExcludePathBuilder,AndMBuilder、OrMBuilder、*NotMBuilder也实现了MatcherBuilder接口,某些对象内部又嵌套了对MatcherBuilder的调用,达到了多条件组合起来匹配的目的,非常灵活。,工厂方法模式注重的是整体对象的创建方法,而建造者模式注重的是部件构建的过程,旨在通过一步一步地精确构造创建出一个复杂的对象。,解决问题:当业务处理流程很长时,可将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到没有对象处理它为止。,APIX 应用了责任链模式来实现中间件的功能,类似的逻辑可参考文章“Gin 中间件的编写和使用”。,首先要定义中间件接口,即下文中的HandlerFunc,然后定义HandlersChain将一组处理函数组合成一个处理链条,最后将HandlersChain插入Context中。,开始执行时,是调用Context的Next函数,遍历每个HandlerFunc,然后将Context自身的引用传入,index是记录当前执行到第几个中间件,当过程中出现不满足继续进行的条件时,可以调用Abort()来终止流程。,下面是一个检查用户是否登录的中间件实现,业务方也可以实现自己的中间件插入到请求处理中,非常灵活。,在服务启动时,注册中间件。,请求进来时,一层一层的通过中间件执行Next函数进入到你设置的下一个中间件中,并且可以通过Context对象一直向下传递下去,当到达最后一个中间件的时候,又向上返回到最初的地方。,该模型常用于记录请求耗时、埋点等场景。,在“Go 语言动手写 Web 框架”[https://geektutu.com/post/gee-day5.html]这篇文章中为我们举了一个浅显易懂的例子。,假设我们应用了中间件 A 和 B,和路由映射的 Handler。c.handlers是这样的[A, B, Handler],c.index初始化为-1。调用c.Next(),接下来的流程是这样的:,最终的调用顺序是part1 -> part3 -> Handler -> part 4 -> part2。,解决问题:解耦观察者和被观察者,尤其是存在多个观察者的场景。,TCC 使用了观察者模式实现了当某 key 的 value 发生变更时执行回调的逻辑。,TccClient对外提供AddListener方法,允许业务注册对某 key 变更的监听,同时开启定时轮询,如果 key 的值与上次不同就回调业务的 callback 方法。,这里的观察者是调用 AddListener 的发起者,被观察者是 TCC 的 key。Callback可以看作只有一个函数的接口,TccClient的通知回调不依赖于具体的实现,而是依赖于抽象,同时Callback对象不是在内部构建的,而是在运行时传入的,让被观察者不再依赖观察者,通过依赖注入达到控制反转的目的。,控制反转和依赖注入是同一个概念的不同角度描述。简而言之,当依赖的外部组件时,不要直接从内部 new,而是从外部传入。,解决场景:支持不同策略的灵活切换,避免多层控制语句的不优雅实现,避免出现如下场景:,通常的做法是定义了一个公共接口,各种不同的算法以不同的方式实现这个接口,环境角色使用这个接口调用不同的算法。,在 GORM 的 clause/clause.go 中使用到策略模式实现 SQL 的拼装。,现实业务中 SQL 语句千变万化,GORM 将 SQL 的拼接过程,拆分成了一个个小的子句,这些子句统一实现clause.Interface这个接口,然后各自在Build方法中实现自己的构造逻辑。,以最简单的分页查询为例,在使用 db 链式调用构建 SQL 时,对Limit、Offset、Order的函数调用最终转化成了Limit子句和OrderBy子句,两者都实现了clause.Interface接口。,Clause 的接口定义:,Limit Clause 的定义:,OrderBy Clause 的定义:,下面的截图中列举了实现clause.Interface接口的所有类,以后 SQL 支持新子句时,创建一个类实现clause.Interface接口,并在函数调用的地方实例化该类,其余执行的代码皆可不变,符合 OOP 中的开闭原则和依赖倒置原则。,解决场景:变量只想初始化一次。,APIX 在埋点中间件中通过单例模式实现了对变量延迟且线程安全地赋值。,Metrics()用来生成 Metric 埋点中间件,在加载的过程,由于 APIX 的路由表还未注册完毕,所以需要把两个变量 metricMap 和 pathMap 的初始化放在中间件的执行过程中,但服务器启动后,这两个变量的值是固定的,没必要反复初始化,其次大量请求过来时,中间件的逻辑会并发执行,存在线程不安全的问题。,故在实现的过程中用到了sync.Once对象,只要声明类型的 once 变量,就可以直接使用它的 Do 方法,Do 方法的参数是一个无参数,无返回的函数。,sync.Once的源码很短,它通过对一个标识值,原子性的修改和加载,来减少锁竞争的。,它有两个特性,一是不管调用 Do 方法多少次,里面的函数只会执行一次;二是如果开始有两个并发调用,可以保证第二个调用不会立即返回,会在获取锁的时候阻塞,等第一个调用执行完毕之后,第二个调用进行二次校验之后就直接返回了。,Sync.Once 有个问题,Do 的过程并不关注 f 函数执行的结果是成功还是失败,当 f()执行失败时,由于本身的机制,没有机会再次初始化了。如果你需要二次初始化,可以看看下面传送门中关于“sync.Once 重试”的文章。

© 版权声明

相关文章