Go 适配器(Adapter)模式

date
Oct 21, 2023
slug
Adaptor
status
Published
tags
设计模式
summary
适配器在Go中的巧妙应用。
type
Post

什么是适配器(Adapter)模式

适配器设计模式(封装器模式) (refactoringguru.cn) 中有一个很经典的例子,我们可以通过它来了解什么是适配器。
notion image
 
假设我们有一个RoundHole(圆孔)类,它有自己的radius(半径)属性,可以通过调用fits(peg RoundPeg)方法来判断某个RoundPeg(圆钉)类实例是否能匹配这个RoundHole,具体实现就是使用getRadius()来比较两者的半径大小,得到结论。
突然之间,我们增加了一个新的类,就像产品经理在项目末期要求你增加一个新的功能一样,我们得到了SquarePeg(方钉)类。
现在我们希望判断方钉是否能匹配到圆孔中,在代码中我们根据fits(RoundPeg)的返回结果来判断是否可行,但是问题在于,fits(RoundPeg)接受的参数是圆钉而非方钉,那么我们该如何实现呢?
 
我们可以想一下有几种实现方式:
  1. 定义一个Peg的接口,将fits接收参数由RoundPeg改为Peg 🧐
这个方法是可行的,听起来不错,但是我们实际想一下,我们将会涉及到fits的代码改动,又由于fits 方法返回一个bool值,假如业务中有很多依赖fits方法的逻辑代码,修改这些逻辑将是一个非常痛苦且容易出错的任务。
在某些情况下我们不希望改动原有的代码,因为重构的成本比增加的成本更高。
更特殊的情况下,假设圆钉和圆孔都是第三方库里面的代码,我们无法直接修改它们的结构,增加一个接口就无从谈起。
  1. 提供一个fitsSquarePeg(peg SquarePeg) 方法 🤔
这个方法也是可行的,但是听起来很糟糕。但我们不得不承认这是大多数人选择的方法,有时候我们的排期不科学,或者是强行插入一个需求,我们只能用这种方式实现需求。
它的好处是能快速的满足需求,坏处是你会发现新增的方法和fits 本身很像,只是换了一个对象,逻辑大部分都是相似的,这种重复会给你的代码带来坏味道(Code smell - Wikipedia)。
  1. 使用适配器(Adapter设计模式 👌
世界上很多充电规范是不一样的,如果你在内地,去到了香港,想要给设备充电你就需要带上转接头。
你会发现这个场景很像我们遇到的问题,把方钉想象成我们原先的充电器,把圆孔想象成香港的插座,那么我们只需要一个转接头(适配器),把方钉接入到转接头中,再把转接头插上插座,那么就能达成我们的目标了。
在上述过程中,转接头就是一个适配器。
让我们回到代码中,我们可以定义一个SquarePegAdapter(圆孔适配器),其接收一个SquarePeg(方钉)作为属性,重写了getRadius()来得到方钉的半径。
这里我们看下书中的伪代码:

如何实现适配器?

下面我们看下更抽象的实现:
notion image
  1. Client是我们业务逻辑代码,作为调用方。
  1. Client Interface 是我们为了完成业务逻辑存在的接口。
  1. Adapter 是我们的适配器,它持有了一个需要被适配的对象Service,重写了Client Interface 里面的method()
  1. Service 是作为被代理的对象。
在未使用适配器之前,我们一般只有Client InterfaceSerivce,我们想通过某种方式让ServiceClient Interface 交互起来,而Adapter 是一种很好的实现。
但请你记住,学习设计模式最重要是学习模式背后的思想,也就是为什么这样设计,这是“形”上的概念,而不是学习该模型的形状,落入到对“形”的模仿中,得不到要领。这里不一定会有Adapter类,Service也不一定会是类,我们要记住的是适配这个概念。

好处和坏处

好处:
  1. 单一职责原则,我们可以将接口和数据转换代码从业务逻辑中抽离,同时我们不会出现一个圆孔类有若干个fitXXX() 方法的情况
  1. 开闭原则,对增加开放,对修改关闭,这能够让我们实现出易维护,可扩展的程序。
坏处:
  1. 代码复杂度会增加,你需要增加新的类和接口。正如前面提到的,直接修改Service或许会更简单。

Go 中的适配器

好了,我们初步了解了适配器模式,下面我们来看下 Go 是怎么实现适配器的。

HTTP Adapter

让我们看下HTTP包中Handler这个接口,它定义了一个非常简单的接口用来响应HTTP请求,接口只有一个方法ServeHTTP(ResponseWriter, *Request) ,只要我们实现这个方法,我们就隐式的实现了Handler接口。
我们实际使用的时候,可以这样做:
懒惰是程序员的美德,很多时候我们只希望提供一个ServeHTTP的具体实现,而不希望定义一个结构体。同时,我们也不希望为每个handler 方法绑定一个结构体,想象一下你有30个API接口,你需要绑定30个结构体,这是一个很恐怖的事情,我们希望能这样做:
但事与愿违,这段代码无法编译,报错原因是:Cannot use 'handler' (type func(w http.ResponseWriter, r *http.Request)) as the type http.Handler Type does not implement 'http.Handler' as some methods are missing: ServeHTTP(ResponseWriter, *Request)
编译器告诉我们,handler这种类型type func(w http.ResponseWriter, r *http.Request) ,它没有实现对应的ServeHTTP方法,所以无法传递给demo这个期望接收一个http.Handler 函数。
这段有些绕,用开头的例子来说,demo这个圆孔无法接收你提供的方钉handler
此时适配器就能很好的解决这个问题,再往下看,我们可以看到一个非常神奇的类型:
Go的一大特点就是,函数是一等公民,变量也是一等公民,这意味着函数可以像变量(value)一样拥有类型,可以赋值,可以作为参数传递给函数,可以作为函数的返回值,得益于这个特性,我们定义了func(ResponseWriter, *Request) 是一种叫做HandlerFunc的类型。
我们再往下看:
这里有一个非常巧妙的操作,HandlerFunc是一种类型,在Go中,类型也可以拥有自己的方法,即使这种类型是函数。得益于这个特性,我们实现了Handler接口,但同时我们又不做任何操作,只是使用f(w, r)来处理逻辑。
我们再看看HandlerFunc 是怎么执行的:
我们仅仅修改了一个地方,就是http.HandlerFunc(handler),就能通过编译了,而这里用到的就是适配器的思想。
http.HandlerFunc(handler)是将我们的handler转换为http.HandlerFunc类型,而http.HandlerFunc实现了Handler接口,所以编译可以通过。那么当我们demo需要执行ServerHTTP的时候,就是调用f(w, r) ,也就是调用handler(w, r),自然就能达到我们想要的目的了。

Ent 中的 Adapter

有了上面这个例子,我们再看看ent是怎么使用设计模式的。
Querier是一个接口,QuerierFunc 是一个适配器,它实现了Querier这个接口。
下面看用法
你会发现这个QuerierFunc(...)接收的匿名函数,就是一个具体的实现,我们通过将这个具体实现,封装成Querier,然后在调用的时候,只需要执行具体实现即可。

总结

在这篇文章中,我们通过实际生活中的例子了解到适配器的思想,同时也学习到了适配器在Go中是如何使用的。
总结一下适配器什么时候可以使用:
  1. 你定义了一个接口,接口里面有若干个方法,通常只有一个。
  1. 你希望根据上下文随时实现这个接口,也就是说你可能会通过匿名函数的方式实现,这意味着你没有具体的实现接口结构,你只实现了方法。
  1. 那么你就可以声明一个Aadpter,把这个接口中的方法,声明为Adapter类型,然后为Adapter实现这个接口。
  1. 接着,在你需要实现的时候,你就可以直接传递给Adapter,交给Adapter调用。 这就是Adapter的强大和灵活之处。
当然我们学习设计模式最重要的是学习它的思想,不要落入到对模版的追求中而舍弃了对思想的理解。

© hhmy 2019 - 2024