看一段下述代码:
var DefaultCacher Cacher
const (
defaultRefreshTTL = time.Second
defaultTTL = time.Minute
defaultTTL4NotInvalid = time.Second * 10
defaultMaxFetchItemsCount = 10
)
type CacheLoader struct {
cacher Cacher // 缓存方法
loader Loader // 回源方法
refreshTTL time.Duration // 自动回源时间,如果有,需要小于 ttl
ttl time.Duration // 缓存过期时间
ttl4NotInvalid time.Duration // 无效数据缓存时间
maxFetchItemsCount int32 // 单次回源函数最多一次性请求多少条数据
}
func NewCacheLoader(loader Loader, cacher Cacher, refreshTTL, ttl, ttl4NotInvalid time.Duration, maxFetchItemsCount int32) (*CacheLoader, error) {
if loader == nil {
return nil, fmt.Errorf("invalid loader")
}
cl := &CacheLoader{
loader: loader,
cacher: cacher,
refreshTTL: refreshTTL,
ttl: ttl,
ttl4NotInvalid: ttl4NotInvalid,
maxFetchItemsCount: maxFetchItemsCount,
}
if cacher == nil {
cl.cacher = DefaultCacher
}
if refreshTTL == 0 {
cl.refreshTTL = defaultRefreshTTL
}
if ttl == 0 {
cl.ttl = defaultTTL
}
if ttl4NotInvalid == 0 {
cl.ttl4NotInvalid = defaultTTL4NotInvalid
}
if maxFetchItemsCount == 0 {
cl.maxFetchItemsCount = defaultMaxFetchItemsCount
}
return cl, nil
}
CacheLoader 是一个我们组内大佬自研的一个自动回源组件,它的主要思想是基于 read throught 模式,将回源的流程交给组件来实现,用户只关心从 CacheLoader get 数据即可,从而大大简化获取数据的流程。
我们不妨想一下上述组件初始化的代码有什么问题?
其实它最主要的问题点就是可选属性太多,导致 new 方法的参数很长,如果我们的组件持续维护,后续增加了很多新的属性,那么可配置项将会越来越多,如果继续沿用现在的设计思路,构造函数的参数列表会变得越来越长,可以想象代码在可读性和易用性上都会变差,同时在使用这个构造函数的时候,我们很容易搞错各参数的顺序,传递进错误的参数值,导致非常隐蔽的 bug(别说了,笔者真的经历过)。
那么有什么方式可以解决上述问题呢?这个就是本篇文章的主题 -- 如何优雅地初始化一个实体。
本文基于 golang 实现。
像上面这样一个实体,有些是必传的,例如回源方法,有些是选传的,可以有默认值,例如 cacher、ttl 等等,甚至还有一些是可以为空的。我们的需求就是能够根据参数的不同声明出不同的实现,其实这个跟重载函数的定义很像。重载函数是函数的一种特殊情况,它允许在同一范围中声明几个功能类似的同名函数,但是这些同名函数的形式参数(指参数的个数、类型或者顺序)不同,也就是说用同一个函数完成不同的功能。
因此利用重载函数的思路,我们可以把不同的配置项组合拆分成不同的方法:
func NewDefaultCacheLoader(loader Loader) *CacheLoader {
return &CacheLoader{
loader: loader,
cacher: DefaultCacher,
refreshTTL: defaultRefreshTTL,
ttl: defaultTTL,
ttl4NotInvalid: defaultTTL4NotInvalid,
maxFetchItemsCount: defaultMaxFetchItemsCount,
}
}
func NewCacheLoaderWithCacher(loader Loader, cacher Cacher) *CacheLoader {
return &CacheLoader{
cacher: cacher,
loader: loader,
refreshTTL: defaultRefreshTTL,
ttl: defaultTTL,
ttl4NotInvalid: defaultTTL4NotInvalid,
maxFetchItemsCount: defaultMaxFetchItemsCount,
}
}
func NewCacheLoaderWithCacherAndTTL(loader Loader, cacher Cacher, ttl time.Duration) *CacheLoader {
return &CacheLoader{
cacher: cacher,
loader: loader,
ttl: ttl,
refreshTTL: defaultRefreshTTL,
ttl4NotInvalid: defaultTTL4NotInvalid,
maxFetchItemsCount: defaultMaxFetchItemsCount,
}
}
// 其他配置方式
因为 go 不支持重载,所以我们需要声明不同的方法名代表不同的配置。这种方式实现简单,但是一旦选项的组合过多,那么对于添加构造方法将是灾难级别的,例如本例中可选参数有 5 个,那么理论上应该有 2^5 也就是 32 个组合,显然如果让我们全部实现这几十个函数,那真的太痛苦了。
解决上述问题的第一个方式就是使用 set() 方法(相信写过 java 的同学对这种 get/set 编程方式再熟悉不过了),说白了就是通过一个构造方法,附加 n 个可选参数的 set() 方法组合出我们最终需要的状态:
func NewCacheLoader(loader Loader) *CacheLoader {
return &CacheLoader{
loader: loader,
}
}
func (cl *CacheLoader) SetCacher(cacher Cacher) {
cl.cacher = cacher
}
func (cl *CacheLoader) SetTTL(ttl time.Duration) {
cl.ttl = ttl
}
// 省略其他 set 方法
这种方式看起来的确是简洁了不少,我们可以任意组合需要的参数。但是同时也出现了另外一些问题:我们没办法集中校验参数的合法性,也就是说可能 A 参数和 B 参数单独使用是合法的,但是组合在一起就是非法了,比如我们的回源组件中的 refreshTTL 和 ttl 两个属性,如果都配置了,那么需要保证 refreshTTL 不大于 ttl,否则自动回源就失去了意义。除此之外还有一个问题,就是会将实体的中间状态暴露出来,就是有可能在参数还没有组装完全之后,我们就直接开始使用,原因就是 set() 方法可以在业务代码的任意位置去调用。
另外一种解决方式就是实现配置化,将可选的参数封装到 Option / Config 中:
type CacheLoader struct {
loader Loader // 回源
option *Option
}
type Option struct {
cacher Cacher // 缓存
refreshTTL time.Duration // 自动回源时间
ttl time.Duration // 缓存过期时间
ttl4NotInvalid time.Duration // 无效数据缓存时间
maxFetchItemsCount int32 // 单次回源函数最多一次性请求多少条数据
}
New 方法变成下面的样子:
func NewCacheLoader(loader Loader, option *Option) *CacheLoader {
// check
return &CacheLoader{
loader: loader,
option: option,
}
}
很好,我们的代码现在看起来简洁了许多,只需要一个 new 方法就可以了,然后在里面进行集中校验,很多优化可能到这里就结束了。然而从上面的代码我们还是能发现一些问题,那就是由于 Option 和它内部的一些属性都不是必选的,对于使用者来说,选择传 nil 还是 Option{} 空值,显然是一个很让人困扰的问题,因为他们并不知道两种选择对于系统会产生什么样的影响。并且由于 Option 是通过使用者传进来的,我们不能保证一定是在当前同一个包下面使用的,因此我们需要将其变量都声明成导出的,考虑到面向对象模式的封装特性,我们不应该把当前包下才会访问的变量直接导出,而应该将其定义为私有变量。 那么有没有什么办法能够摆脱掉这些问题呢?
建造者模式
建造者模式是 23 种经典的设计模式之一,通过名字我们就可以知道它是一种创建型的设计模式。建造者模式将一个复杂的对象的构建(WithConfig)与它的表示(Build)分离,使得同样的构建过程可以创建不同的表示。通过使用建造者模式,可以屏蔽建造的具体细节,用户不需要知道对象的建造过程和细节就可以创建出复杂的对象;并且它通过先设置好建造者的变量,然后再一次性地创建对象,能够避免无效状态,让对象一直处于有效的状态;同时在设置好所有需要的参数之后进行集中校验,能够避免使用 set() 时由于属性之间存在依赖关系而产生的由于 set() 的顺序错乱而导致的校验失败的问题,解决最大值小于最小值的尴尬情况。 上述实体 Builder 模式的改造方式如下:
type CacheLoader struct {
cacher Cacher // 缓存
loader Loader // 回源
refreshTTL time.Duration // 自动回源时间
ttl time.Duration // 缓存过期时间
ttl4NotInvalid time.Duration // 无效数据缓存时间
maxFetchItemsCount int32 // 单次回源函数最多一次性请求多少条数据
}
type Builder struct {
*CacheLoader
}
func NewBuilder(loader Loader) *Builder {
return &Builder{
&CacheLoader{
loader: loader,
},
}
}
func (b *Builder) WithCacher(cacher Cacher) *Builder {
b.CacheLoader.cacher = cacher
return b
}
func (b *Builder) WithTTL(ttl time.Duration) *Builder {
b.CacheLoader.ttl = ttl
return b
}
// with...
func (b *Builder) Build() (*CacheLoader, error) {
// TODO 参数集中校验
// 校验失败返回 error
return b.CacheLoader, nil
}
func main() {
_, _ = NewBuilder(defaultLoader).WithTTL(time.Second*5).Build()
}
Builder 模式的使用方式也很简单,就是先 new 一个 builder,然后链式的往里面追加参数,最后通过 Build 方法进行构造业务实体,这种做法的好处就是消除了实体的中间状态,构造出来的一定是我们能使用的最终状态,并且参数通过在 Build() 中统一注入,我们可以很方便的去进行组合参数的校验。
Builder 模式固然很不错,对于复杂参数的场景很适合。但是需要对已有的业务实体进行一次包装,并实现对应的方法,这显然是比较重的一步操作,那么有没有什么办法来省略这层包装呢?
选项模式
接下来就该祭出我们的选项模式了。
Go 语言支持高阶函数,高阶函数首先是一个函数,但是与一般函数不同的是,它的形参或者返回值也是一个或者多个函数。例如下述代码所示:
type fn func(int) int
func bar(num int) fn {
return func(i int) int {
return i << 1
}
}
上述代码接收一个 int 类型的参数,返回一个 fn 函数,这个 fn 函数接收一个 int 参数并且返回一个 int 参数,但是内部操作我们可以自己来指定。通过这种方式,我们可以实现对 int 的任意操作,只要返回值类型也满足即可。 那么如何通过选项模式来改造我们的 CacheLoader 初始化呢?
这里直接贴出代码如下:
type CacheLoader struct {
cacher Cacher // 缓存
loader Loader // 回源
refreshTTL time.Duration // 自动回源时间
ttl time.Duration // 缓存过期时间
ttl4NotInvalid time.Duration // 无效数据缓存时间
maxFetchItemsCount int32 // 单次回源函数最多一次性请求多少条数据
}
type Optional func(cl *CacheLoader)
func WithCacher(cacher Cacher) Optional {
return func(cl *CacheLoader) {
cl.cacher = cacher
}
}
func WithRefreshTTL(ttl time.Duration) Optional {
return func(cl *CacheLoader) {
cl.refreshTTL = ttl
}
}
func WithTTL(ttl time.Duration) Optional {
return func(cl *CacheLoader) {
cl.ttl = ttl
}
}
func WithTTL4NotInvalid(ttl4NotInvalid time.Duration) Optional {
return func(cl *CacheLoader) {
cl.ttl4NotInvalid = ttl4NotInvalid
}
}
func WithMaxFetchItemsCount(cnt int32) Optional {
return func(cl *CacheLoader) {
cl.maxFetchItemsCount = cnt
}
}
func NewCacheLoader(loader Loader, options ...Optional) (*CacheLoader, error) {
ch := &CacheLoader{
loader: loader,
}
for _, op := range options {
op(ch)
}
// TODO 参数集中校验
return ch, nil
}
使用选项模式,我们要定义一组选项函数,这组代码传入一个参数,然后返回一个函数,返回的这个函数会设置自己的 CacherLoader 参数。在 new 方法中,我们只需要按照我们的需求传入对应的选项方法即可:
cl, err := NewCacheLoader(defaultLoader, WithRefreshTTL(time.Second), WithTTL(time.Second * 10))
通过使用选项模式,我们可以实现高度的配置化,并且代码相对来说比较简洁,完美保留了面向对象的封装特性。同时代码比较容易理解和维护,在拓展方面也表现的特别友好,在需要增加 / 减少参数的时候,我们只需要增减对应的 option 函数即可。
总结
以上我们讲了六种实体初始化的方式。虽然上面我们一直强调建造者模式和选项模式的优点,它们也确实是特别好的设计,但并不是说我们总是需要选择使用这两个方式,或者说它们并不总是最好的选择。比如说当我们的属性很有限的时候,我们完全可以选择第一种或者第二种方式,声明不同的构造方法,这样实现起来和用起来都很简洁。又比如说如果我们的属性是在使用过程中可以动态配置的,那么我们完全可以使用 set() 的方式。
没有最好的,只有最合适的。