探索 Go 标准库中的优雅设计模式:函数与接口的结合
https://juejin.cn/post/7400969744793583650?searchId=202501161004413E00B7A93CC59EFEB656
前言
在 Go 语言的标准库(特别是在 net/http 包中),我们会发现一种非常优雅且简洁的编程模式,即通过函数类型与接口的结合来实现灵活且可扩展的代码。这不仅仅在代码复用、简化测试方面带来了极大方便,还提升了整体的开发效率和代码质量。这篇文章我们将探讨这一模式的工作原理及其诸多好处。
基础概念
首先,我们需要理解 Go 语言中的几个基础概念:
- 接口(Interface):在 Go 中接口是一组方法的集合,任何实现了这些方法的类型都可以被认为实现了这个接口。
- 类型定义(Type Definition):Go 语言允许定义新的类型。例如,可以将某种函数签名定义为一种类型。
- 方法(Method):可以为某个类型定义方法。该类型可以是结构体、基础类型甚至是函数类型。
实战分析
我们来看 net/http 包中是如何使用这些概念构建一个非常灵活的 HTTP 服务器的。
先看一张图,总览一下这个灵活的 HTTP 服务器:
net/http 包中的设计
Go 语言源代码 net/http/server.go 版本:Go 1.22.2
首先来看看标准库中 Handler 接口的定义:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
任何实现了 ServeHTTP 方法的类型都可以被用作 HTTP 请求的处理器。
一般情况下,我们都习惯于使用一个结构体来实现一个接口,尽管可能使用空的结构体,例如:
type MyHandler struct {}
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world"))
}
而这样无疑会增加很多重复且无用的代码,因为我们并不关注 MyHandler 结构体。因此,我们不想每次都去定义一个结构体和绑定方法,能不能使用简单的函数实现?
那么,我们如何将一个普通的函数转变为符合 Handler 接口的类型呢?
这就需要用到以下的代码模式,我们接着来看源码:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
这里定义了一种新的函数类型 HandlerFunc,其签名与 ServeHTTP 方法一致。并为其实现了 ServeHTTP 方法,这样就使得所有这种类型的函数都符合 Handler 接口。
那这种模式具体该怎么使用呢?
实战代码
我们通过具体的代码示例来更好地理解这一模式的使用:
package main
import (
"net/http"
"fmt"
)
// 一些通用的逻辑封装
func helloHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello world"))
}
func main() {
// http.HandlerFunc(helloHandler) 将 helloHandler 函数转换为 HandlerFunc 类型
http.Handle("/hello", http.HandlerFunc(helloHandler))
// 或者直接使用 http.HandleFunc,效果完全相同
http.HandleFunc("/hello", helloHandler)
http.ListenAndServe(":8080", nil)
}
在这个例子中:
- 我们定义了一个处理请求的函数 helloHandler;
- 然后通过 http.Handle 将 url 与处理函数绑定起来,而 http.Handle 函数需要一个实现了 Handler 接口的对象;我们通过 http.HandlerFunc(helloHandler) 将其转换为 HandlerFunc 类型,这样 helloHandler 就自动实现了 ServeHTTP 方法,从而成为一个合法的 Handler;
- 我们还可以使用 http.HandleFunc 直接将函数关联到某个路径,这其实是标准库对上述转换的简单封装。
我们点进去看一眼源码:
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
// 版本控制,暂时不用关注
if use121 {
DefaultServeMux.mux121.handleFunc(pattern, handler)
} else {
DefaultServeMux.register(pattern, HandlerFunc(handler))
}
}
func Handle(pattern string, handler Handler) {
// 版本控制,暂时不用关注
if use121 {
DefaultServeMux.mux121.handle(pattern, handler)
} else {
DefaultServeMux.register(pattern, handler)
}
}
源码底层实现就是将 “路径(url) + 处理器” 注册到 DefaultServeMux(默认的 HTTP 请求多路复用器),这样我们使用 url 访问 web 请求时,就可以路由到对应的处理器进行处理了。
这样就轻松实现了将一个简单处理函数,注册进默认的 HTTP 路由器中的逻辑,如果需要多个处理函数,也可以轻松实现,而不用为每一个处理函数都定义一个空的结构体,然后绑定方法,这样代码会更加简洁和易读。
http.Handle("/hello", http.HandlerFunc(helloHandler))
http.Handle("/hello", http.HandlerFunc(helloHandler2))
http.Handle("/hello", http.HandlerFunc(helloHandler3))
如果你仔细观察,会发现 http.ListenAndServe 的第二个参数也是接口类型 Handler,我们使用了标准库 net/http 内置的路由,因此传入的值是 nil。
http.ListenAndServe(":8080", nil)
标准库 net/http 内置的路由为 ServeMux,本身也实现了 ServeHTTP 方法,也是一个合法的 Handler。
type ServeMux struct {
mu sync.RWMutex
tree routingNode
index routingIndex
patterns []*pattern // TODO(jba): remove if possible
mux121 serveMux121 // used only when GODEBUG=httpmuxgo121=1
}
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(StatusBadRequest)
return
}
var h Handler
if use121 {
h, _ = mux.mux121.findHandler(r)
} else {
h, _, r.pat, r.matches = mux.findHandler(r)
}
h.ServeHTTP(w, r)
}
它的主要作用就是,通过传入的 url 匹配对应的处理函数 mux.findHandler(我们刚刚注册进去的 ),然后通过 h.ServeHTTP(w, r) 实现对不同 url 的处理逻辑,这样一个灵活的 HTTP 服务器就搭建完成了,通过这个 http.ListenAndServe(":8080", nil) 就可以轻松启动起来(具体内部如何建立连接,处理请求不是本文重点,后续再详细讲解源码)。
一个思考问题:那如果这个地方我们传入的是一个实现了 Handler 接口的结构体呢?
是不是就可以完全托管所有的 HTTP 请求,后续怎么路由,怎么处理,请求前后增加什么功能,都可以自定义了,慢慢地,就变成了一个功能丰富的 Web 框架了。 (有兴趣的可以先看看 gin 框架,后续我们有机会也详细聊一下一个功能丰富的 Web 框架如何搭建)
结论
通过了解和实践这种编程模式,我们可以更高效地编写简洁、灵活且高质量的代码,这不仅提高了开发效率,也使得代码更加易于维护和扩展。
除了代码简洁,这种模式还有什么优点呢?
接下来就总结一下这种模式的好处:
- 简洁性与可读性:通过这种方式,我们可以轻松地将简单的函数转换为复杂接口的实现。这使得代码更加简洁和直观,易于维护。
- 灵活性:这种设计模式允许开发者在处理不同需求时能够灵活调整代码。通过定义不同的方法和处理器,能够轻松适应变化。
- 减少样板代码:我们不必每次都去定义一个结构体和绑定方法,只需编写符合特定签名的函数即可。这减少了样板代码,让我们可以将更多时间花费在业务逻辑上。
- 增强代码复用性:由于函数可以非常方便地转换为实现接口的类型,这使得代码模块化和复用性变得更加容易。我们可以编写通用函数来处理许多场景,然后通过这种模式组合实际需求。
- 简化测试:函数的单元测试通常更为容易。通过这种模式,我们可以直接对函数进行测试,然后再将其集成到实际应用中,从而简化了测试流程。
希望通过这篇文章,大家能更好地理解 Go 语言中函数类型和接口结合所带来的强大之处,并在实际开发中合理运用这一模式。
最后举个简单的例子,自己领悟一下“代码的整洁之道”:
package main
import "fmt"
// 定义一个操作接口
type Operation interface {
Execute(a, b int) int
}
// 使用一个类型,将函数适配成符合接口的方法
type OperationFunc func(a, b int) int
// 让函数类型实现接口
func (f OperationFunc) Execute(a, b int) int {
return f(a, b)
}
// 计算函数,接受一个 Operation 接口
func Calculate(a, b int, op Operation) int {
return op.Execute(a, b)
}
func main() {
// 定义一些操作函数
addition := OperationFunc(func(a, b int) int {
return a + b
})
subtraction := OperationFunc(func(a, b int) int {
return a - b
})
// 使用操作函数进行计算
fmt.Println("Addition:", Calculate(10, 5, addition)) // Output: Addition: 15
fmt.Println("Subtraction:", Calculate(10, 5, subtraction)) // Output: Subtraction: 5
}
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付
