Appearance
适配器与外观模式
适配器(Adapter)和外观(Facade)都是结构型模式,它们都不改变现有系统,而是通过「包装」提供新的接口。区别在于:适配器把一个接口转换成另一个接口(让不兼容的能协作);外观为复杂子系统提供一个简化的统一入口。本章我们看 Go 如何用接口和嵌入轻量地实现这两个模式。
一、适配器模式
1. 意图
适配器模式:将一个类的接口转换成客户端期望的另一个接口。适配器让原本接口不兼容的类可以一起工作。
经典比喻:你的笔记本只有 USB-C 接口,但鼠标是 USB-A 的,你需要一个「转接头」。这个转接头就是适配器。
2. Go 实现:实现目标接口,包装被适配者
Go 的适配器写法非常直接:定义一个适配器结构体,让它实现目标接口,内部持有一个被适配者。
go
package main
import "fmt"
// === 目标接口:客户端期望的接口 ===
type Logger interface {
Log(level, msg string)
}
// === 被适配者:第三方日志库,接口不兼容 ===
type LegacyLogger struct{}
// 它的方法签名跟 Logger 不一样
func (l *LegacyLogger) WriteMessage(category, message string) {
fmt.Printf("[Legacy][%s] %s\n", category, message)
}
// === 适配器:把 LegacyLogger 包装成 Logger ===
type LegacyLoggerAdapter struct {
legacy *LegacyLogger
}
func NewLegacyLoggerAdapter(l *LegacyLogger) *LegacyLoggerAdapter {
return &LegacyLoggerAdapter{legacy: l}
}
// 实现 Logger 接口,内部转发给被适配者
func (a *LegacyLoggerAdapter) Log(level, msg string) {
// 适配转换:level -> category
a.legacy.WriteMessage(level, msg)
}
// 客户端只依赖 Logger 接口
func doWork(l Logger) {
l.Log("INFO", "服务启动")
l.Log("ERROR", "数据库连接失败")
}
func main() {
legacy := &LegacyLogger{}
adapter := NewLegacyLoggerAdapter(legacy)
doWork(adapter)
}关键点:
Logger是目标接口(客户端想要的样子)。LegacyLogger是被适配者(已有的、不兼容的)。LegacyLoggerAdapter实现Logger,内部持有LegacyLogger,把Log调用转成WriteMessage。- 客户端
doWork完全感知不到适配器的存在,它只跟Logger打交道。
3. 类适配器 vs 对象适配器(Go 只有对象适配器)
GoF 原书把适配器分为两种:
- 类适配器:同时继承「目标接口」和「被适配者」,利用多重继承在适配时覆盖方法。需要语言支持多重继承(如 C++)或同时
extends+implements(如 Java)。 - 对象适配器:通过组合(持有被适配者的引用)实现适配。
Go 没有继承,所以 Go 只有对象适配器。这反而是好事——对象适配器比类适配器更灵活,能适配被适配者的子类型,且不会破坏封装。
4. 实战示例:适配旧版日志库到新接口
下面模拟一个更真实的场景:老系统用 fmt.Printf 风格的日志,新系统统一用结构化的 Logger 接口,我们用适配器平滑迁移。
go
package main
import (
"fmt"
"strings"
)
// 新系统统一接口
type StructuredLogger interface {
Info(msg string, fields map[string]any)
Error(msg string, fields map[string]any)
}
// 老系统的日志器:只能输出文本
type OldPrinter struct{}
func (OldPrinter) PrintLine(prefix, text string) {
fmt.Printf("[%s] %s\n", prefix, text)
}
// 适配器:把 OldPrinter 适配成 StructuredLogger
type PrinterAdapter struct {
printer OldPrinter
}
func NewPrinterAdapter(p OldPrinter) *PrinterAdapter {
return &PrinterAdapter{printer: p}
}
func (a *PrinterAdapter) Info(msg string, fields map[string]any) {
a.printer.PrintLine("INFO", formatLine(msg, fields))
}
func (a *PrinterAdapter) Error(msg string, fields map[string]any) {
a.printer.PrintLine("ERROR", formatLine(msg, fields))
}
func formatLine(msg string, fields map[string]any) string {
parts := []string{msg}
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
return strings.Join(parts, " ")
}
// 业务代码用新接口
func processOrder(id string, logger StructuredLogger) {
logger.Info("订单处理开始", map[string]any{"order_id": id})
logger.Error("库存不足", map[string]any{"order_id": id, "stock": 0})
}
func main() {
// 老打印机通过适配器接入新系统
old := OldPrinter{}
adapter := NewPrinterAdapter(old)
processOrder("ORD-001", adapter)
}这种「老接口 + 适配器 + 新接口」的迁移方式在重构中极其常用:你不必一次性重写所有代码,只要给老组件套个适配器,就能让它在新的接口体系下工作。
二、外观模式
1. 意图
外观模式:为子系统中的一组接口提供一个一致的、简化的界面。外观让子系统更容易使用,客户端不需要了解子系统内部的复杂结构。
经典比喻:你点外卖,只需要点几下手机,背后涉及订单系统、支付系统、商家接单、骑手派单、结算等一大堆子系统。外卖 App 就是这一大堆子系统的「外观」。
2. Go 实现:提供一个简化的 API 层
外观模式在 Go 里就是一个普通的结构体/函数,把多个子系统的调用聚合起来:
go
package main
import "fmt"
// === 子系统 1:库存 ===
type InventoryService struct{}
func (InventoryService) Check(itemID string, qty int) bool {
fmt.Printf("检查库存: %s x%d\n", itemID, qty)
return true
}
func (InventoryService) Deduct(itemID string, qty int) {
fmt.Printf("扣减库存: %s x%d\n", itemID, qty)
}
// === 子系统 2:支付 ===
type PaymentService struct{}
func (PaymentService) Charge(amount float64) bool {
fmt.Printf("扣款: %.2f\n", amount)
return true
}
// === 子系统 3:物流 ===
type ShippingService struct{}
func (ShippingService) Ship(orderID, address string) {
fmt.Printf("发货: 订单 %s -> %s\n", orderID, address)
}
// === 子系统 4:通知 ===
type NotificationService struct{}
func (NotificationService) Send(userID, msg string) {
fmt.Printf("通知 %s: %s\n", userID, msg)
}
// === 外观:把上述子系统聚合成一个简单的下单接口 ===
type OrderFacade struct {
inventory InventoryService
payment PaymentService
shipping ShippingService
notify NotificationService
}
func NewOrderFacade() *OrderFacade {
return &OrderFacade{
inventory: InventoryService{},
payment: PaymentService{},
shipping: ShippingService{},
notify: NotificationService{},
}
}
// PlaceOrder 是外观提供的「简化 API」
func (f *OrderFacade) PlaceOrder(userID, itemID string, qty int, amount float64, address string) bool {
fmt.Println("=== 开始下单 ===")
if !f.inventory.Check(itemID, qty) {
f.notify.Send(userID, "库存不足")
return false
}
if !f.payment.Charge(amount) {
f.notify.Send(userID, "支付失败")
return false
}
f.inventory.Deduct(itemID, qty)
orderID := "ORD-" + itemID
f.shipping.Ship(orderID, address)
f.notify.Send(userID, "下单成功,订单号 "+orderID)
fmt.Println("=== 下单完成 ===")
return true
}
func main() {
facade := NewOrderFacade()
facade.PlaceOrder("user-001", "SKU-100", 2, 199.0, "北京朝阳区")
}外观模式的关键:
OrderFacade内部持有 4 个子系统,对外只暴露一个PlaceOrder方法。- 客户端不需要知道库存、支付、物流、通知怎么协作,外观替它编排好。
- 外观不增加新功能,只是「整合」。
3. 外观 vs 适配器的区别
两者都「包装」,但意图不同:
| 维度 | 适配器 | 外观 |
|---|---|---|
| 目标 | 转换 一个 不兼容接口 | 简化 一组 子系统接口 |
| 对象数量 | 一对一(一个适配器包一个被适配者) | 一对多(一个外观包多个子系统) |
| 动机 | 让已有接口能用 | 让复杂变简单 |
| 是否改变接口 | 是(转成新接口) | 是(提供新的高层接口) |
简单记忆:适配器是「翻译官」,外观是「前台」。
三、标准库中的应用
1. io 包的适配器
Go 标准库的 io 包是适配器模式的宝库。它定义了 io.Reader 和 io.Writer 两个核心接口,并提供大量「把某种类型适配成 Reader/Writer」的工具:
strings.NewReader:把string适配成io.Reader。bytes.NewReader:把[]byte适配成io.Reader。bufio.NewReader:把一个io.Reader包装成带缓冲的 Reader。ioutil.NopCloser/io.NopCloser:给一个 Reader 加上Close方法,适配成io.ReadCloser。
go
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func main() {
// 把 string 适配成 io.Reader
r := strings.NewReader("hello\nworld\n")
// 再用 bufio 包装成带缓冲的 Reader
br := bufio.NewReader(r)
for {
line, err := br.ReadString('\n')
if line != "" {
fmt.Printf("读到: %s", line)
}
if err == io.EOF {
break
}
}
// io.NopCloser:把 Reader 适配成 ReadCloser
rc := io.NopCloser(strings.NewReader("data"))
defer rc.Close()
fmt.Println("rc 类型已实现 io.ReadCloser")
}io 包的设计完美体现了适配器思想:定义极简接口(Reader/Writer),然后提供一堆适配器让各种数据源/汇都满足这个接口。这是 Go 标准库最具影响力的设计之一。
2. sql.DB 对 driver 的适配
database/sql 的 sql.DB 并不直接连接数据库,它把具体的协议交互委托给 driver.Driver 和 driver.Conn。可以理解为:sql.DB 是面向用户的「外观」,而 driver 包是各数据库驱动的「适配目标接口」。
driver.Conn适配具体的数据库连接。sql.DB在其之上提供连接池、预处理、事务等高层 API(外观)。
这种「外观 + 适配器」组合是 Go 标准库的常见架构:定义抽象接口(适配目标),用外观提供易用的高层 API。
3. net/http 的适配
http.ResponseWriter 和 http.Request 也可以看作外观:HTTP 协议本身很复杂(状态行、头部、body、chunked 编码等),但 http 包把这些细节封装到两个对象里,开发者只跟它们打交道就能写 Web 服务。
四、综合实战:日志系统重构
下面这个例子综合使用适配器和外观:把一个老日志库适配进新系统,再用外观提供一个简洁的「业务日志」入口。
go
package main
import (
"fmt"
"strings"
"time"
)
// === 老日志库(不可改) ===
type FileLogger struct {
path string
}
func (f *FileLogger) AppendLine(line string) {
fmt.Printf("[FILE:%s] %s\n", f.path, line)
}
// === 新系统接口 ===
type AppLogger interface {
Info(msg string)
Error(msg string)
}
// === 适配器:FileLogger -> AppLogger ===
type FileLoggerAdapter struct {
inner *FileLogger
}
func NewFileLoggerAdapter(path string) *FileLoggerAdapter {
return &FileLoggerAdapter{inner: &FileLogger{path: path}}
}
func (a *FileLoggerAdapter) Info(msg string) {
a.inner.AppendLine(fmt.Sprintf("[INFO] %s", msg))
}
func (a *FileLoggerAdapter) Error(msg string) {
a.inner.AppendLine(fmt.Sprintf("[ERROR] %s", msg))
}
// === 外观:业务日志,附加时间戳和上下文 ===
type BusinessLoggerFacade struct {
logger AppLogger
context map[string]string
}
func NewBusinessLogger(logger AppLogger) *BusinessLoggerFacade {
return &BusinessLoggerFacade{logger: logger, context: map[string]string{}}
}
func (b *BusinessLoggerFacade) WithContext(k, v string) *BusinessLoggerFacade {
b.context[k] = v
return b
}
func (b *BusinessLoggerFacade) Info(msg string) {
b.logger.Info(b.decorate(msg))
}
func (b *BusinessLoggerFacade) Error(msg string) {
b.logger.Error(b.decorate(msg))
}
func (b *BusinessLoggerFacade) decorate(msg string) string {
parts := []string{time.Now().Format("15:04:05")}
for k, v := range b.context {
parts = append(parts, k+"="+v)
}
parts = append(parts, msg)
return strings.Join(parts, " ")
}
// === 业务使用 ===
func checkout(userID string, logger *BusinessLoggerFacade) {
logger.WithContext("user", userID).Info("开始结算")
logger.Error("优惠券已过期")
}
func main() {
// 老日志库通过适配器接入
adapter := NewFileLoggerAdapter("app.log")
// 外观进一步封装
biz := NewBusinessLogger(adapter)
checkout("user-123", biz)
}这个例子体现了实际项目中的典型分层:
- 底层是老
FileLogger(不可改)。 - 适配器把它适配成新接口
AppLogger。 - 外观再封装一层,加上业务上下文(时间戳、用户 ID 等),让业务代码调用更简单。
- 业务代码
checkout只用一个BusinessLoggerFacade,不关心底层是文件还是别的。
五、外观模式 vs 中介者模式
外观和中介者(Mediator,本系列不展开)都「集中管理交互」,但区别:
- 外观是 单向 的:外观调用子系统,子系统不会回调外观。
- 中介者是 双向 的:同事对象之间通过中介者互相通信。
外观简化外部对子系统的调用,中介者简化子系统内部对象之间的相互引用。如果子系统内部对象相互依赖混乱,那是中介者的活;如果只是对外接口太复杂,用外观。
六、使用外观模式的注意事项
- 外观不封装所有功能:外观只提供「常用」的简化 API,复杂的细粒度操作仍应允许客户端直接访问子系统。不要让外观变成「唯一入口」,否则又回到了过度封装。
- 外观可以有多个:一个子系统可以为不同使用方提供多个外观(如「管理后台外观」「客户端外观」)。
- 外观不是「上帝对象」:不要把所有逻辑都塞进外观,否则外观本身会变成新的复杂源。外观只做编排,业务逻辑应在子系统内。
- 外观与分层架构:Go 项目里,
service层、handler层本质上都是外观——它们把底层多个 repository、client 的调用整合成对外接口。
七、小结
- 适配器把一个不兼容接口转换成目标接口,让原本无法协作的对象一起工作;Go 只有对象适配器(靠组合 + 实现目标接口)。
- 外观为复杂子系统提供一个简化的统一入口,降低使用成本;Go 里就是一个聚合了多个子系统的结构体。
- 适配器是「一对一翻译」,外观是「一对多整合」,意图不同。
- Go 标准库大量使用这两个模式:
io包的各类 Reader/Writer 适配器、sql.DB对 driver 的适配和外观、http包对 HTTP 协议的封装。 - 重构中「老接口 + 适配器 + 新接口 + 外观」是平滑迁移的常见组合。
- 外观不要变成上帝对象,它只负责编排,业务逻辑留给子系统。
下一篇讲装饰器与代理模式——它们结构相似(都是包装),但意图截然不同。