Go 适配器(Adapter)模式

175 阅读9分钟

什么是适配器(Adapter)模式

适配器设计模式(封装器模式) (refactoringguru.cn) 中有一个很经典的例子,我们可以通过它来了解什么是适配器。

Untitled.png

假设我们有一个RoundHole(圆孔)类,它有自己的radius(半径)属性,可以通过调用fits(peg RoundPeg)方法来判断某个RoundPeg(圆钉)类实例是否能匹配这个RoundHole,具体实现就是使用getRadius()来比较两者的半径大小,得到结论。

突然之间,我们增加了一个新的类,就像产品经理在项目末期要求你增加一个新的功能一样,我们得到了SquarePeg(方钉)类。

  • 伪代码,可以忽略

    // 假设你有两个接口相互兼容的类:圆孔(Round•Hole)和圆钉(Round•Peg)。
    class RoundHole is
        constructor RoundHole(radius) { …… }
    ​
        method getRadius() is
            // 返回孔的半径。
    ​
        method fits(peg: RoundPeg) is
            return this.getRadius() >= peg.getRadius()
    ​
    class RoundPeg is
        constructor RoundPeg(radius) { …… }
    ​
        method getRadius() is
            // 返回钉子的半径。// 但还有一个不兼容的类:方钉(Square•Peg)。
    class SquarePeg is
        constructor SquarePeg(width) { …… }
    ​
        method getWidth() is
            // 返回方钉的宽度。
    

现在我们希望判断方钉是否能匹配到圆孔中,在代码中我们根据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()来得到方钉的半径。

这里我们看下书中的伪代码:

// 适配器类让你能够将方钉放入圆孔中。它会对 RoundPeg 类进行扩展,以接收适
// 配器对象作为圆钉。
class SquarePegAdapter extends RoundPeg is
    // 在实际情况中,适配器中会包含一个 SquarePeg 类的实例。
    private field peg: SquarePeg
​
    constructor SquarePegAdapter(peg: SquarePeg) is
        this.peg = peg
​
    method getRadius() is
        // 适配器会假扮为一个圆钉,其半径刚好能与适配器实际封装的方钉搭配起来。
        return peg.getWidth() * Math.sqrt(2) / 2

如何实现适配器?

下面我们看下更抽象的实现:

  1. Client是我们业务逻辑代码,作为调用方。
  2. Client Interface 是我们为了完成业务逻辑存在的接口。
  3. Adapter 是我们的适配器,它持有了一个需要被适配的对象Service,重写了Client Interface 里面的method()
  4. Service 是作为被代理的对象。

在未使用适配器之前,我们一般只有Client InterfaceSerivce,我们想通过某种方式让ServiceClient Interface 交互起来,而Adapter 是一种很好的实现。

但请你记住,学习设计模式最重要是学习模式背后的思想,也就是为什么这样设计,这是“形”上的概念,而不是学习该模型的形状,落入到对“形”的模仿中,得不到要领。这里不一定会有Adapter类,Service也不一定会是类,我们要记住的是适配这个概念。

好处和坏处

好处:

  1. 单一职责原则,我们可以将接口和数据转换代码从业务逻辑中抽离,同时我们不会出现一个圆孔类有若干个fitXXX() 方法的情况
  2. 开闭原则,对增加开放,对修改关闭,这能够让我们实现出易维护,可扩展的程序。

坏处:

  1. 代码复杂度会增加,你需要增加新的类和接口。正如前面提到的,直接修改Service或许会更简单。

Go 中的适配器

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

HTTP Adapter

  • 你可能会好奇这些设计的作者是谁,下面是我翻到的资料

    ”Russ Cox利用函数类型也可以拥有自己的方法这个特性巧妙设计出了http包的HandlerFunc类型,这样通过显式转型即可让一个普通函数成为满足http.Handler接口的类型。“ —— 《Go语言精进之路》

让我们看下HTTP包中Handler这个接口,它定义了一个非常简单的接口用来响应HTTP请求,接口只有一个方法ServeHTTP(ResponseWriter, *Request) ,只要我们实现这个方法,我们就隐式的实现了Handler接口。

// A Handler responds to an HTTP request.
// ...
type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}

我们实际使用的时候,可以这样做:

func demo(h http.Handler) {}
​
type MyHandler struct {
}
​
func (h *MyHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
  //TODO implement me
  panic("implement me")
}
​
func main() {
  // init
  myHandler := &MyHandler{}
  demo(myHandler)
}

懒惰是程序员的美德,很多时候我们只希望提供一个ServeHTTP的具体实现,而不希望定义一个结构体。同时,我们也不希望为每个handler 方法绑定一个结构体,想象一下你有30个API接口,你需要绑定30个结构体,这是一个很恐怖的事情,我们希望能这样做:

func demo(h http.Handler) {}
​
func handler(w http.ResponseWriter, r *http.Request) {}
​
func main() {
  // 这行代码在编译时会报错
  demo(handler) 
  }

但事与愿违,这段代码无法编译,报错原因是: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

此时适配器就能很好的解决这个问题,再往下看,我们可以看到一个非常神奇的类型:

// The HandlerFunc type is an **adapter** to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

Go的一大特点就是,函数是一等公民,变量也是一等公民,这意味着函数可以像变量(value)一样拥有类型,可以赋值,可以作为参数传递给函数,可以作为函数的返回值,得益于这个特性,我们定义了func(ResponseWriter, *Request) 是一种叫做HandlerFunc的类型。

我们再往下看:

// adapter...
type HandlerFunc func(ResponseWriter, *Request)
​
// 实现了Handler接口
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
  f(w, r)
}

这里有一个非常巧妙的操作,HandlerFunc是一种类型,在Go中,类型也可以拥有自己的方法,即使这种类型是函数。得益于这个特性,我们实现了Handler接口,但同时我们又不做任何操作,只是使用f(w, r)来处理逻辑。

我们再看看HandlerFunc 是怎么执行的:

func demo(h http.Handler) {}
​
func handler(w http.ResponseWriter, r *http.Request) {}
​
func main() {
  // 编译通过
  demo(http.HandlerFunc(handler))
}

我们仅仅修改了一个地方,就是http.HandlerFunc(handler),就能通过编译了,而这里用到的就是适配器的思想。

http.HandlerFunc(handler)是将我们的handler转换为http.HandlerFunc类型,而http.HandlerFunc实现了Handler接口,所以编译可以通过。那么当我们demo需要执行ServerHTTP的时候,就是调用f(w, r) ,也就是调用handler(w, r),自然就能达到我们想要的目的了。

Ent 中的 Adapter

有了上面这个例子,我们再看看ent是怎么使用设计模式的。

// Querier wraps the basic Query method that is implemented
// by the different builders in this file.
Querier interface {
	// Query runs the given query on the graph and returns its result.
	Query(context.Context, Query) (Value, error)
}

// The QuerierFunc type is an adapter to allow the use of ordinary// function as Querier. If f is a function with the appropriate signature,
// QuerierFunc(f) is a Querier that calls f.
QuerierFunc func(context.Context, Query) (Value, error)

// Query calls f(ctx, q).func
(f QuerierFunc) Query(ctx context.Context, q Query) (Value, error) {
	return f(ctx, q)
}

Querier是一个接口,QuerierFunc 是一个适配器,它实现了Querier这个接口。

下面看用法

var qr Querier = QuerierFunc(func(ctx context.Context, q Query) (Value, error) {
		query, ok := q.(Q1)
		if !ok {
			return nil, fmt.Errorf("unexpected query type %T", q)
		}
		if err := selectOrGroup.sqlScan(ctx, query, v); err != nil {
			return nil, err
		}
		if k := rv.Kind(); k == reflect.Pointer && rv.Elem().CanInterface() {
			return rv.Elem().Interface(), nil
		}
		return v, nil
	})

	...

你会发现这个QuerierFunc(...)接收的匿名函数,就是一个具体的实现,我们通过将这个具体实现,封装成Querier,然后在调用的时候,只需要执行具体实现即可。

	qr.Query(ctx, rootQuery)

Untitled 1.png

总结

在这篇文章中,我们通过实际生活中的例子了解到适配器的思想,同时也学习到了适配器在Go中是如何使用的。

总结一下适配器什么时候可以使用:

  1. 你定义了一个接口,接口里面有若干个方法,通常只有一个。
  2. 你希望根据上下文随时实现这个接口,也就是说你可能会通过匿名函数的方式实现,这意味着你没有具体的实现接口结构,你只实现了方法。
  3. 那么你就可以声明一个Aadpter,把这个接口中的方法,声明为Adapter类型,然后为Adapter实现这个接口。
  4. 接着,在你需要实现的时候,你就可以直接传递给Adapter,交给Adapter调用。 这就是Adapter的强大和灵活之处。

当然我们学习设计模式最重要的是学习它的思想,不要落入到对模版的追求中而舍弃了对思想的理解。

关注公众号【code路漫漫】,每周更新一篇高质量文章。