探索 Go 标准库中的优雅设计模式:函数与接口的结合

Posted by kzdgt on Thursday, January 16, 2025

探索 Go 标准库中的优雅设计模式:函数与接口的结合

https://juejin.cn/post/7400969744793583650?searchId=202501161004413E00B7A93CC59EFEB656

前言

在 Go 语言的标准库(特别是在 net/http 包中),我们会发现一种非常优雅且简洁的编程模式,即通过函数类型与接口的结合来实现灵活且可扩展的代码。这不仅仅在代码复用、简化测试方面带来了极大方便,还提升了整体的开发效率和代码质量。这篇文章我们将探讨这一模式的工作原理及其诸多好处。

基础概念

首先,我们需要理解 Go 语言中的几个基础概念:

  1. 接口(Interface):在 Go 中接口是一组方法的集合,任何实现了这些方法的类型都可以被认为实现了这个接口。
  2. 类型定义(Type Definition):Go 语言允许定义新的类型。例如,可以将某种函数签名定义为一种类型。
  3. 方法(Method):可以为某个类型定义方法。该类型可以是结构体、基础类型甚至是函数类型。

实战分析

我们来看 net/http 包中是如何使用这些概念构建一个非常灵活的 HTTP 服务器的。

先看一张图,总览一下这个灵活的 HTTP 服务器:

image.png

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)
}

在这个例子中:

  1. 我们定义了一个处理请求的函数 helloHandler;
  2. 然后通过 http.Handle 将 url 与处理函数绑定起来,而 http.Handle 函数需要一个实现了 Handler 接口的对象;我们通过 http.HandlerFunc(helloHandler) 将其转换为 HandlerFunc 类型,这样 helloHandler 就自动实现了 ServeHTTP 方法,从而成为一个合法的 Handler;
  3. 我们还可以使用 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 框架如何搭建)

结论

通过了解和实践这种编程模式,我们可以更高效地编写简洁、灵活且高质量的代码,这不仅提高了开发效率,也使得代码更加易于维护和扩展。

除了代码简洁,这种模式还有什么优点呢?

接下来就总结一下这种模式的好处:

  1. 简洁性与可读性:通过这种方式,我们可以轻松地将简单的函数转换为复杂接口的实现。这使得代码更加简洁和直观,易于维护。
  2. 灵活性:这种设计模式允许开发者在处理不同需求时能够灵活调整代码。通过定义不同的方法和处理器,能够轻松适应变化。
  3. 减少样板代码:我们不必每次都去定义一个结构体和绑定方法,只需编写符合特定签名的函数即可。这减少了样板代码,让我们可以将更多时间花费在业务逻辑上。
  4. 增强代码复用性:由于函数可以非常方便地转换为实现接口的类型,这使得代码模块化和复用性变得更加容易。我们可以编写通用函数来处理许多场景,然后通过这种模式组合实际需求。
  5. 简化测试:函数的单元测试通常更为容易。通过这种模式,我们可以直接对函数进行测试,然后再将其集成到实际应用中,从而简化了测试流程。

希望通过这篇文章,大家能更好地理解 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
}

「真诚赞赏,手留余香」

kzdgt Blog

真诚赞赏,手留余香

使用微信扫描二维码完成支付