Go 适配器(Adapter)模式

什么是适配器(Adapter)模式

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

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

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

  • 伪代码,可以忽略

    kotlin 复制代码
    // 假设你有两个接口相互兼容的类:圆孔(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()来得到方钉的半径。

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

scala 复制代码
// 适配器类让你能够将方钉放入圆孔中。它会对 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接口。

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

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

go 复制代码
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个结构体,这是一个很恐怖的事情,我们希望能这样做:

scss 复制代码
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

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

scss 复制代码
// 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的类型。

我们再往下看:

scss 复制代码
// 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 是怎么执行的:

scss 复制代码
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是怎么使用设计模式的。

scss 复制代码
// 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这个接口。

下面看用法

go 复制代码
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,然后在调用的时候,只需要执行具体实现即可。

markdown 复制代码
	qr.Query(ctx, rootQuery)

总结

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

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

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

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

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

相关推荐
王码码20352 小时前
Go语言的测试:从单元测试到集成测试
后端·golang·go·接口
王码码20352 小时前
Go语言中的测试:从单元测试到集成测试
后端·golang·go·接口
嵌入式×边缘AI:打怪升级日志3 小时前
使用JsonRPC实现前后台
前端·后端
小码哥_常3 小时前
从0到1:Spring Boot 中WebSocket实战揭秘,开启实时通信新时代
后端
lolo大魔王4 小时前
Go语言的异常处理
开发语言·后端·golang
IT_陈寒6 小时前
Python多进程共享变量那个坑,我差点没爬出来
前端·人工智能·后端
码事漫谈6 小时前
2026软考高级·系统架构设计师备考指南
后端
AI茶水间管理员7 小时前
如何让LLM稳定输出 JSON 格式结果?
前端·人工智能·后端
其实是白羊8 小时前
我用 Vibe Coding 搓了一个 IDEA 插件,复制URI 再也不用手动拼了
后端·intellij idea
用户8356290780518 小时前
Python 操作 Word 文档节与页面设置
后端·python