设计模式踩坑记录

那时候的回答基本是"了解单例、工厂、观察者",背完定义就开始在脑子里搜索项目里用过没有——当然是没有。

后来进了一家所谓的"架构驱动"公司,项目里到处都是设计模式的痕迹:抽象工厂工厂出接口,装饰器装饰着装饰器,代理套着代理。

为什么需要设计模式?

教科书会告诉你:设计模式是解决特定问题的成熟方案,提高代码复用性、可维护性、扩展性。

但实际项目里,设计模式更多时候解决的是沟通成本。团队里10个人,对"怎么解耦"、“怎么扩展"有10种理解。设计模式给出了一个共享词汇表,你一说"这里用策略模式”,大家就知道大概是什么结构,不用再从零开始解释。

还有一个现实情况:代码写出来不是给人看的,是给人改的。需求永远在变,如果每次改需求都要重构整个模块,那开发效率就是问题。设计模式本质上是在预判变化——预判哪些地方可能会变,提前做隔离和扩展点。

但预判本身就是风险。过度设计是设计模式最大的坑,后面详细说。

常见模式在项目里的实际样子

这里只挑几个在项目里真正用得上、也容易用烂的模式说说。每个模式配一个真实场景和一段能跑的代码。

工厂方法:别让外部决定怎么创建对象

场景:项目里有多种支付渠道(支付宝、微信、银联),每个渠道的初始化参数、签名方式、回调处理都不一样。如果每次用的时候直接 new AlipayPayment(config),代码会到处散满创建逻辑,改个渠道配置要改一堆文件。

反模式:在业务代码里直接new对象,散落各处。

// 订单服务里
func CreateOrder(order *Order) error {
    payment := &AlipayPayment{
        AppID:     "alipay-app-123",
        PrivateKey: "...",
    }
    return payment.Process(order)
}

// 退款服务里又重复一遍
func RefundOrder(orderID string) error {
    payment := &AlipayPayment{
        AppID:     "alipay-app-123",
        PrivateKey: "...",
    }
    return payment.Refund(orderID)
}

合理做法:用工厂方法,把创建逻辑集中到一处。

type PaymentFactory interface {
    CreatePayment() Payment
}

type AlipayFactory struct {
    Config AlipayConfig
}

func (f *AlipayFactory) CreatePayment() Payment {
    return &AlipayPayment{
        AppID:      f.Config.AppID,
        PrivateKey: f.Config.PrivateKey,
    }
}

type WechatFactory struct {
    Config WechatConfig
}

func (f *WechatFactory) CreatePayment() Payment {
    return &WechatPayment{
        AppID:  f.Config.AppID,
        Secret: f.Config.Secret,
    }
}

// 使用时
factory := getPaymentFactory("alipay") // 配置中心或DI容器里拿
payment := factory.CreatePayment()
payment.Process(order)

踩坑:工厂本身也可能被滥用。如果工厂只有一个实现,那就不用工厂,直接new就行。工厂是为了应对"多种实现"或者"创建过程复杂"的场景,不是为了让代码看起来"架构化"。

单例:全局状态最脆弱

场景:数据库连接池、日志器、配置加载器这些确实只需要一个实例的东西。

教科书写法:懒加载+双重检查锁。

type DatabasePool struct {
    connections []*sql.DB
}

var (
    instance *DatabasePool
    once     sync.Once
)

func GetDatabasePool() *DatabasePool {
    once.Do(func() {
        instance = &DatabasePool{
            connections: initializeConnections(),
        }
    })
    return instance
}

现实问题:单例会让代码变得不可测。单元测试时需要mock数据库,但单例直接返回真实对象,没法替换。更糟的是,单例会隐藏依赖关系——一个函数调用 Logger.GetLogger(),你从函数签名看不出它依赖日志系统。

更好的做法:通过依赖注入(DI)框架或者构造函数传入单例,让依赖关系显式化。

// 构造函数注入
type OrderService struct {
    db *DatabasePool
    logger Logger
}

func NewOrderService(db *DatabasePool, logger Logger) *OrderService {
    return &OrderService{
        db:     db,
        logger: logger,
    }
}

// 测试时可以传入mock对象
func TestOrderService_Create(t *testing.T) {
    mockDB := &MockDatabasePool{}
    mockLogger := &MockLogger{}
    service := NewOrderService(mockDB, mockLogger)
    // 测试代码...
}

观察者模式:解耦但也可能变成事件地狱

场景:订单状态变更后,需要通知多个下游系统:库存系统扣减、物流系统创建运单、用户系统发送通知、数据分析系统记录埋点。如果把这些调用都写在订单服务里,代码会越来越臃肿。

简单实现:事件订阅。

type OrderEvent struct {
    OrderID    string
    Status     string
    Timestamp  time.Time
}

type Observer interface {
    OnOrderEvent(event OrderEvent)
}

type OrderEventBus struct {
    observers []Observer
}

func (bus *OrderEventBus) Subscribe(observer Observer) {
    bus.observers = append(bus.observers, observer)
}

func (bus *OrderEventBus) Publish(event OrderEvent) {
    for _, observer := range bus.observers {
        go observer.OnOrderEvent(event) // 异步处理,避免阻塞
    }
}

// 订单服务发布事件
func (s *OrderService) UpdateStatus(orderID string, status string) {
    // 更新订单状态...
    event := OrderEvent{
        OrderID:   orderID,
        Status:    status,
        Timestamp: time.Now(),
    }
    s.eventBus.Publish(event)
}

踩坑:事件流转会变得难以追踪。一个订单状态变更,可能触发十几个事件,每个事件又触发其他事件,最后都不知道状态是怎么变成这样的。排查问题时,需要画一张完整的事件流转图。

调试建议:

  1. 记录事件发布日志,包括事件内容、订阅者列表
  2. 给每个事件加trace ID,全链路可追踪
  3. 关键业务事件同步处理,非关键业务异步处理

策略模式:消除if-else,但也别过度

场景:用户认证方式有多种:密码、手机验证码、第三方登录(微信、GitHub)。如果用if-else写,代码会像这样:

func Authenticate(user *User, authType string, credential interface{}) error {
    if authType == "password" {
        return authenticateByPassword(user, credential.(string))
    } else if authType == "sms" {
        return authenticateBySMS(user, credential.(string))
    } else if authType == "wechat" {
        return authenticateByWechat(user, credential.(WechatAuth))
    } else if authType == "github" {
        return authenticateByGithub(user, credential.(GithubAuth))
    }
    return errors.New("unsupported auth type")
}

每次新增一种认证方式,都要修改这个函数,违反了开闭原则。

策略模式改造

type AuthStrategy interface {
    Authenticate(user *User, credential interface{}) error
}

type PasswordAuthStrategy struct {
    // 可能需要的依赖...
}

func (s *PasswordAuthStrategy) Authenticate(user *User, credential interface{}) error {
    password, ok := credential.(string)
    if !ok {
        return errors.New("invalid credential type")
    }
    // 密码验证逻辑...
    return nil
}

type SMSAuthStrategy struct {
    smsService SMSService
}

func (s *SMSAuthStrategy) Authenticate(user *User, credential interface{}) error {
    code, ok := credential.(string)
    if !ok {
        return errors.New("invalid credential type")
    }
    // 短信验证码验证逻辑...
    return nil
}

type AuthService struct {
    strategies map[string]AuthStrategy
}

func (s *AuthService) RegisterStrategy(authType string, strategy AuthStrategy) {
    s.strategies[authType] = strategy
}

func (s *AuthService) Authenticate(user *User, authType string, credential interface{}) error {
    strategy, ok := s.strategies[authType]
    if !ok {
        return fmt.Errorf("unsupported auth type: %s", authType)
    }
    return strategy.Authenticate(user, credential)
}

踩坑:策略模式用多了会变"策略爆炸"。一个简单的if-else拆成5个类,文件数量激增,新人看代码要跳来跳去。如果策略只有2-3个,而且不太可能扩展,那就直接用if-else,别强行上模式。

实践里踩过的那些坑

坑1:过度设计,为了用模式而用模式

刚接触设计模式那段时间,看什么都想套模式。一个简单的CRUD模块,硬生生拆成了Repository、Factory、Builder、Strategy、Observer,每个类几行代码,调用链七拐八弯。

结果是:

  • 新同事看不懂,老同事一段时间后也看不懂
  • 改个小功能要动好几个文件
  • 调试时要在脑子里画出整个调用图

后来学会了一个简单原则:如果不用模式也能解决问题,那就不用模式。模式是为了解决复杂度,不是为了制造复杂度。

坑2:模式选择错误,越改越复杂

有次做缓存系统,用了装饰器模式来叠加缓存逻辑:先加本地缓存装饰器,再加Redis缓存装饰器,再加预热装饰器。结果运行时发现调用链太长,性能有问题,调试也困难。

后来改成了责任链模式,每个缓存节点独立判断是否命中、是否传递给下一个节点,逻辑清晰多了。

选择模式前先问自己:

  • 这个模式能解决什么具体问题?
  • 有没有更简单的方案?
  • 团队成员都熟悉这个模式吗?

坑3:模式僵化,需求变了不好改

用了模板方法模式实现支付流程:下单→验证→支付→回调→完成。后来业务发展,需要支持"预授权→消费→完成"的流程,模板方法改起来很麻烦,因为骨架已经定死了。

如果预判到流程可能大变,那用策略模式或者组合模式会更灵活。模板方法适合流程稳定、细节变化的场景。

什么时候该用,什么时候不用

这个判断比"怎么用"更重要。

该用模式的场景

  1. 重复的代码结构:同一套逻辑在多个地方出现,用模式可以复用
  2. 明确的变化点:某些地方未来肯定会变,提前做隔离
  3. 团队有共识:团队都熟悉这个模式,沟通成本低
  4. 复杂度足够高:简单的逻辑直接写,复杂的逻辑才需要模式来理清关系

不该用模式的场景

  1. 只有一次的使用:一个类只有一个地方用,没必要抽象
  2. 未来不会变:逻辑很稳定,不太可能扩展
  3. 团队不熟悉:用了反而增加理解成本
  4. 性能敏感:抽象层会影响性能,且优化难度大

判断标准

# 在心里问这三个问题
1. 这个模式能解决什么问题?——如果答不上来,就别用
2. 不用这个模式会怎么样?——如果也没什么影响,就别用
3. 六个月后回头看,这个模式还能看懂吗?——如果不确定,就简化

写在最后

设计模式这东西,说到底是一种"经验封装"。前人踩过坑、总结出方案,后人可以避免重复踩坑。但经验不是教条,具体问题具体分析。

代码写得再"优雅",如果没人看得懂、没法维护,那就是失败。设计模式是工具,不是目的。真正的高手,是知道什么时候该用、什么时候该克制的那个人。

参考的资源和实践环境:

  • Go语言项目,主要在微服务架构下使用
  • 依赖注入框架:Wire(Google出品的编译时DI)
  • 项目规模:5-10人团队,代码量约10万行
  • 主要模式:工厂、策略、观察者、装饰器、责任链

如果你想深入了解某个模式的细节,建议直接看Go标准库里是怎么用的。标准库的代码质量高、注释详尽,比教科书里的示例代码有参考价值得多。

可用性说明:本文发布于 2021 年 3 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-设计模式踩坑记录https://blog.thinkmoon.cn/post/108-design-patterns-theory-practice-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!