一说 23 种设计模式,很多人脑子里全是类图、继承、虚函数。但在 Go 里面,你会发现很多模式的“标准画法”根本画不出来——Go 压根没有继承。我最近把 GoF 的经典模式逐个用 Go 重写了一遍,整理成一个合集,这篇是合集的总纲:不只给代码,更想讲清楚,为什么同一个模式在 Go 里会长成另一副样子。如果你是已经写过一段时间 Go,想知道设计模式怎么落地;或者刚从 Java/C++ 转过来,被“没有继承怎么做多态”卡住,这份合集都适合你。我会把每个模式的核心意图、Go 实现骨架、以及我实际踩过的坑一起讲,避免你照着书抄出一个“看起来很面向对象,但完全不像 Go”的代码。
1. 为什么在 Go 里谈 23 种设计模式之前,先忘掉“继承”
1.1 GoF 的教科书画法在 Go 里会失真
GoF 的 23 种设计模式,本质上是在 Smalltalk / C++ 时代的类继承体系下总结出来的。很多模式当时的解法是“用抽象类定义骨架,让子类补步骤”或者“用多层继承扩展行为”,这在 C++、Java 里是顺理成章的。但 Go 语言从一开始就没有继承,只有组合,struct 嵌入也不是继承,而是一种语法糖级的委托。你用type A struct { B }的时候,A 拥有 B 的方法,但 A 并不是 B 的子类型,接口层面的多态不靠这个来传递。
所以,如果照着 Java 的类图画 Go,你会发现很多模式根本画不出来。例如模板方法模式,Java 里是抽象类加 protected 方法,Go 里没有抽象类,怎么处理?答案是接口加嵌入加函数字段,或者直接借用sort.Interface那种思路:算法骨架在外部函数里固定,具体步骤由传入的类型提供。同样的意图,表达方式完全不同。
1.2 Go 语言的四个特性,决定了所有模式的实现方式
第一,接口是隐式实现的。Go 里不需要写implements,只要一个类型的方法集合满足接口就能传递。这个特性影响巨大:适配器、代理、装饰器这些“包一层”的模式,代码量会少很多,而且接口可以定义在使用方,而不是被依赖方,天然规避了循环依赖。
第二,结构体嵌入虽然不等于继承,但可以让我们复用方法。组合模式、装饰器模式中的“递归组合”,Go 用结构体切片加接口就能表达得特别直接。关键是你要接受“组合优先于继承”不是一句口号,而是写 Go 时的默认姿势。
第三,函数是一等公民。策略模式、命令模式、责任链模式,在 Java 里可能要定义一堆函数接口和匿名类,在 Go 里只需要一个type Handler func(...)。行为型模式因此被大幅简化,但也带来一个问题:如果滥用函数类型,会让代码难以调试,因为堆栈里全是匿名函数。
第四,goroutine 和 channel。观察者模式在 Go 里最自然的实现不是回调注册,而是订阅 channel。责任链模式也可以结合并发做流水线。这个特性让 Go 的并发模式不是额外复杂度,而是“用 channel 就能写出来的语言级能力”。
2. 创建型模式:Go 不靠构造函数,靠函数
2.1 单例模式:直接用 sync.Once,不要自己加锁
单例模式的意图是保证一个类型全局只有一个实例。Go 里最简单的写法其实是包级全局变量,在init里初始化,这是“饿汉式”。但如果你是做懒加载,或者初始化依赖运行时的某些配置,就得用sync.Once。
package config import "sync" var ( once sync.Once cfg *Config ) func Load() *Config { once.Do(func() { cfg = &Config{} // 在这里读取文件、初始化大对象 }) return cfg }你可能会觉得用 double-checked locking 更酷,但在 Go 里直接用sync.Once就够了,它内部已经把底层原子操作和锁处理好了。我见过有人自己写一套if nil { lock; if nil { create } },出问题的概率远高于收益。真正需要注意的是:不要依赖sync.Once去处理“初始化失败后想重试”的场景,因为 Once 只保证执行一次,失败也会被记住。如果初始化可能失败,可以用atomic.Value配合普通锁,或者干脆不做懒加载,改成启动时初始化并直接 panic。
还有一个更根本的提醒:单例本质是全局可变状态,会影响测试的隔离性。如果你要单例只是为了省一点内存,或者只是表达“这个组件全局唯一”,那没问题;但如果它内部有可变字段,你的测试用例之间就可能互相污染。
2.2 工厂方法模式与抽象工厂:返回接口、隐藏具体类型
Go 没有构造函数,所以包级别的NewXxx函数承担了对象创建的职责。最简单的入口是一个简单工厂:
type Button interface { Render() } type WindowsButton struct{} func (WindowsButton) Render() {} type LinuxButton struct{} func (LinuxButton) Render() {} func NewButton(os string) Button { switch os { case "windows": return WindowsButton{} default: return LinuxButton{} } }严格来说,这是“简单工厂”,不是工厂方法。工厂方法模式强调的是一个创建者接口,由不同创建者决定实例化哪个产品:
type Dialog interface { CreateButton() Button } type WindowsDialog struct{} func (WindowsDialog) CreateButton() Button { return WindowsButton{} }使用方只依赖Dialog和Button接口,不关心具体类型。抽象工厂模式则是进一步规定“一个工厂要能生产一系列相关产品”,比如一个报表工厂必须同时产出 Header 和 Body,你只需要定义type ReportFactory interface { CreateHeader() Header; CreateBody() Body }。
Go 中实现这些模式时,最常见的错误是返回具体类型而不是接口。我建议所有对外暴露的创建函数都写成返回接口,至少返回一个不变量,比如“这个函数返回的对象一定满足某行为”。这样可以让你在以后替换实现时不用改调用方。当然也别过度:如果这个对象只有一个实现,返回具体类型也说得通,Go 社区对此并不像 Java 那样强求。
2.3 建造者模式:首选 Functional Options
建造者模式原本是为了解决“对象构造参数太多、可读性差、默认值不清晰”的问题。经典写法是 Builder 结构体加链式方法,strings.Builder就是典型例子。但在日常业务代码中,我更推荐 Go 社区流行的 functional options,也就是函数选项模式。
type Server struct { host string port int timeout time.Duration } type Option func(*Server) func WithHost(h string) Option { return func(s *Server) { s.host = h } } func WithPort(p int) Option { return func(s *Server) { s.port = p } } func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } } func NewServer(opts ...Option) *Server { s := &Server{ host: "0.0.0.0", port: 8080, timeout: 3 * time.Second, } for _, opt := range opts { opt(s) } return s }使用方可以NewServer(WithHost("127.0.0.1"), WithPort(8081)),只设置关心的参数,其余走默认值。相比传统 Builder,它的好处是不用维护一个独立的 Builder 状态结构体,也不会因为链式调用中断而产生半成品对象。缺点也很明显:匿名函数在堆栈中可读性略差,如果你追求可读性,可以用具名函数WithPort的这种写法,已经足够了。
2.4 原型模式:必须先决定浅拷贝还是深拷贝
原型模式的核心是Clone,Go 里的实现很容易踩坑。因为 Go 是值传递,最简单的一行cp := *p只能做浅拷贝。对于包含 slice、map、指针字段的结构体,浅拷贝会让新旧两个对象共享底层数据。
func (p *Product) Clone() *Product { cp := *p cp.Tags = append([]string(nil), p.Tags...) return &cp }如果嵌套结构体更深,手动复制很痛苦,可以用encoding/json的序列化做深拷贝,但前提是字段都是可导出的,而且 JSON 序列化可能丢字段。也可以用encoding/gob,但同样的,不可导出字段没法处理。我的建议是:原型模式在 Go 里不是高频模式,只有在创建对象成本很高、且你需要保存多个互不影响的副本时才用。真要写,就先想清楚这个类型的“共享边界”在哪,宁可多写两个复制函数,也不要迷信“序列化一次就万事大吉”。
3. 结构型模式:用包裹和组合代替继承
3.1 适配器模式:隐式接口带来的最小改动
适配器模式的本质是“让原本不兼容的接口协同工作”。Go 的隐式接口实现让适配往往不需要额外写wrapper:只要目标接口的方法集合和具体类型匹配,直接传就行。最典型的例子是标准库里的http.Handler,它是一个接口,而http.HandlerFunc是一个函数类型,通过适配把普通函数变成接口实现。
如果两个结构之间确实存在方法名不一致、参数不匹配的情况,你才需要写一个包装结构体:
type LegacyStore struct{} func (l *LegacyStore) Query(id string) ([]byte, error) { return nil, nil } type NewStore interface { Get(ctx context.Context, id string) ([]byte, error) } type StoreAdapter struct { legacy *LegacyStore } func (a *StoreAdapter) Get(ctx context.Context, id string) ([]byte, error) { if ctx.Err() != nil { return nil, ctx.Err() } return a.legacy.Query(id) }这里StoreAdapter就是适配器。注意,适配器不一定非要在“引入新接口”时写。更常见的场景是:你的代码里定义一个小接口,第三方库的结构体恰好满足,那它已经适配好了。这种“使用方定义接口”的思路是 Go 接口哲学的核心,能有效避免包之间互相依赖大接口。
3.2 装饰器模式:日志、鉴权、重试都可以用包装函数
装饰器模式的意图是动态扩展对象功能。Go 里最优雅的实现是用函数类型,因为装饰器本质就是“输入一个函数,输出一个增强后的函数”。
type Handler func(ctx context.Context, req Request) Response func Logging(h Handler) Handler { return func(ctx context.Context, req Request) Response { log.Printf("before request") resp := h(ctx, req) log.Printf("after request") return resp } } func Metrics(h Handler) Handler { return func(ctx context.Context, req Request) Response { start := time.Now() resp := h(ctx, req) record(req, time.Since(start)) return resp } }你可以在入口处这样组装:handler := Metrics(Logging(handleRequest))。注意装饰器的顺序:外层函数先执行“before”部分,后执行“after”部分,所以顺序会影响日志内容和耗时统计范围。如果你需要装饰的是接口而不是函数,也可以写一个结构体包装原接口,比如type loggingReader struct { r io.Reader },实现Read方法时加日志。这里最容易犯的错误是:装饰器改变了原接口的方法集,或者对外暴露了包装类,导致调用方还能拿到原始对象绕过装饰。记住,装饰器的目标是不改变接口,只增强行为。
3.3 代理模式:控制对真实对象的访问
代理模式和装饰器结构上很像,都是包一层,但意图不同:装饰器是增强功能,代理是控制访问。代理可以做权限校验、请求过滤、缓存、远程调用代理。Go 里最常见的写法是:
type Service interface { Fetch(ctx context.Context) ([]byte, error) } type RealService struct{} func (RealService) Fetch(ctx context.Context) ([]byte, error) { return []byte("data"), nil } type CachedProxy struct { svc Service cache sync.Map } func (p *CachedProxy) Fetch(ctx context.Context) ([]byte, error) { if v, ok := p.cache.Load("key"); ok { return v.([]byte), nil } data, err := p.svc.Fetch(ctx) if err == nil { p.cache.Store("key", data) } return data, err }CachedProxy 是代理而不是装饰器,因为它拦截了请求,用自己的缓存逻辑决定了是否调用真实对象。这个模式的实际价值在于:调用方只需要依赖 Service 接口,替换真实实现或代理实现都很方便。这也是 Go 接口隐式实现的好处。
3.4 组合模式:统一叶子与容器的接口
组合模式适合表达树形结构:文件系统、组织架构、菜单树。在 Go 中,用接口切片写起来非常自然:
type Node interface { Size() int64 } type File struct { name string size int64 } func (f File) Size() int64 { return f.size } type Directory struct { name string children []Node } func (d *Directory) Add(n Node) { d.children = append(d.children, n) } func (d *Directory) Size() int64 { var total int64 for _, child := range d.children { total += child.Size() } return total }核心在于 Directory 和 File 都实现了 Node,外部调用方不需要知道当前节点是叶子还是容器。Go 中需要注意指针接收者和值接收者的统一:如果Directory.Add要求指针接收者,那么children里的元素应该是*Directory或接口类型,否则你往切片里加的是副本,之后修改不会生效。我习惯把所有实现都写成指针接收者,除非这个类型是类似小枚举的不可变值。
3.5 桥接、外观、享元:让“大象”拆开
桥接模式要解决的问题是“抽象和实现各自独立变化”。Java 里需要抽象类加实现类,Go 里只需要结构体里持有接口字段。比如一个消息通知功能,发送渠道有邮件、短信,消息级别有普通、紧急,如果全用继承要写 4 个类,桥接模式则写成:
type Sender interface { Send(content string) } type Notify struct { sender Sender urgent bool } func (n *Notify) Notify(content string) { // 组合不同维度的逻辑 n.sender.Send(content) }这样发送渠道和消息级别是两个维度,相互独立。
外观模式的目标是给复杂子系统提供一个简单门面。Go 的包设计里经常用到:一个service.go文件里定义一个 Service 结构体,内部聚合 Repository、Cache、Messaging 等多个组件,对外只暴露几个方法。
享元模式强调共享不可变对象以减少内存。Go 中可以用sync.Map做缓存,但关键是共享对象必须不可变。如果对象会被修改,一旦有人改了其中一个字段,所有引用方都会受到影响。比如颜色对象:
type Color struct { r, g, b uint8 } var colorCache sync.Map func GetColor(r, g, b uint8) *Color { key := uint32(r)<<16 | uint32(g)<<8 | uint32(b) if v, ok := colorCache.Load(key); ok { return v.(*Color) } c := &Color{r: r, g: g, b: b} colorCache.Store(key, c) return c }4. 行为型模式:策略、观察者、责任链……都可以用函数和 channel 重写
4.1 策略模式与模板方法模式:策略用函数,模板方法用接口
策略模式在 Go 里有两种常见写法:一种是用接口抽象策略,另一种是直接用函数字段。接口的好处是策略本身可以带状态,函数的好处是轻量。比如排序策略,最简单的是:
type LessFunc func(i, j int) bool func SortBy(data []int, less LessFunc) { // 内部使用排序算法 }调用方传入一个闭包,这就是策略模式。你不需要像 Java 那样定义Comparator接口再写一个实现类。
模板方法模式在 Go 里也更像“接口加外部函数”。Go 标准库的sort.Interface就是一个很好的例子:排序算法骨架在sort.Sort函数里,具体比较和交换由你传入的类型实现:
type ByAge []Person func (p ByAge) Len() int { return len(p) } func (p ByAge) Less(i, j int) bool { return p[i].Age < p[j].Age } func (p ByAge) Swap(i, j int) { p[i], p[j] = p[j], p[i] } sort.Sort(ByAge(people))sort.Sort是固定的算法骨架,ByAge是具体步骤,两者通过接口连接。这就是 Go 对模板方法模式的重新演绎:不依赖继承,而是依赖接口。如果你需要一个“算法骨架在结构体内部固定,但步骤可以由外部注入”的场景,可以用结构体持有函数字段的方式模拟。
4.2 观察者模式:用 channel 和订阅表实现事件总线
观察者模式在 Go 中的最佳实践是 channel 加订阅表。我给你一个简化版的事件总线:
type Event struct { Topic string Data interface{} } type EventBus struct { mu sync.RWMutex subs map[string][]chan Event } func (b *EventBus) Subscribe(topic string, ch chan Event) { b.mu.Lock() defer b.mu.Unlock() b.subs[topic] = append(b.subs[topic], ch) } func (b *EventBus) Publish(topic string, data interface{}) { b.mu.RLock() defer b.mu.RUnlock() evt := Event{Topic: topic, Data: data} for _, ch := range b.subs[topic] { ch <- evt } }这里有个大坑:Publish是同步发送,如果订阅者的 channel 没有消费者,发布者会被卡住。解决办法是给 channel 加缓冲区,或者用非阻塞发送:
select { case ch <- evt: default: // 订阅者处理不过来,丢弃或者打日志 }但我建议设计时不要默默丢弃,至少要在日志里体现。观察者的另一个坑是退订。如果订阅者已经退出,还在订阅表里,发布者向无人接收的 channel 发送就会产生 goroutine 泄漏,甚至 panic。退订实现需要一个unsubscribe机制,通常用sync.Map来维护,保证不会向已关闭的 channel 发送。
4.3 责任链模式和命令模式:中间件与操作对象
责任链模式在做 Web 中间件时已经成了标配。核心思想是把每个处理节点串成链,每个节点决定是否继续往下传。Go 里最简单的中间件原型:
type Middleware func(next Handler) Handler func Auth(m Middleware) Middleware { return func(next Handler) Handler { return Handler(func(ctx Context) { if !checkAuth(ctx) { return } next.Handle(ctx) }) } }实际项目里更常见的是维护一个[]Middleware,按顺序组装成最终 Handler。责任链的好处是每个节点只关心自己的职责,便于增删。
命令模式则是把“请求”封装成对象,用来做参数化、队列、日志回放、撤销操作。Go 里可以用type Command func() error,但如果要支持撤销,建议定义一个接口:
type Command interface { Execute() error Undo() error }我在实际经验里发现,命令模式最适合“一组操作需要被记录下来并重放”的场景,比如编辑器撤销重做、运维任务批量执行。如果只是调用一个函数,别硬套命令模式。
4.4 状态模式、备忘录模式、中介者模式:状态机、快照和中央协调
状态模式在 Go 里常见的方法是定义状态接口和上下文结构体。状态接口里的方法接收Context引用,让状态在切换时能够改变上下文内的当前状态。简单示例:
type State interface { Handle(ctx *Context) } type IdleState struct{} func (s IdleState) Handle(ctx *Context) { // 执行逻辑 ctx.SetState(&RunningState{}) }对于简单场景,状态模式也可以用map[State]func(ctx)实现,把每个状态的行为变成一个函数,减少类型数量。但注意,状态切换如果带很多上下文数据,还是用接口和结构体清晰。
备忘录模式用于保存和恢复对象状态。Go 里要重点处理深拷贝问题。我经常用json.Marshal/json.Unmarshal做快照,但只适用于字段全是可导出的情况。也可以定义专门的快照结构体,只保存需要恢复的字段,这样更可控。
中介者模式是让组件之间不直接引用,而是通过一个中央对象通信。比如聊天室、GUI 框架中的事件分发。Go 中可以用一个结构体持有所有组件引用,并提供Notify方法协调。要注意的是中介者容易膨胀成“上帝对象”,所以要控制它的职责边界。
4.5 迭代器、解释器、访问者:这三个少用但要认得
迭代器模式在 Go 里最常用的其实是内置的range,它能遍历 slice、map、channel。如果自定义数据结构需要遍历,你可以实现Next() (T, bool),或者更符合 Go 风格的是在 channel 上 range。Go 1.23 之后也开始支持 range over function,这让自定义迭代器更轻量。
解释器模式适合构建 DSL(领域特定语言)。它需要定义表达式接口和各个节点结构,比如一个加减乘除表达式树,每个节点实现Evaluate() float64。在 Go 里解释器代码量并不大,但容易变得抽象,实际项目中用得不多,更多被递归下降解析器替代。
访问者模式在 Go 里是最难受的一个。因为 Go 没有方法重载,想实现“访问者要能根据被访问对象的具体类型做不同处理”,通常只能靠类型断言:
switch v := node.(type) { case *NumberNode: // ... case *BinaryNode: // ... }这其实是 Go 对访问者模式的替代方案,简单直接,代码也更容易读。所以我不建议在 Go 里强行写经典访问者模式的类型结构,而是遇到需要对多种类型做分派时,直接用switch type。
5. 23 种模式的 Go 实现速查表
| 分类 | 模式 | Go 实现要点 | 常用度 |
|---|---|---|---|
| 创建型 | 单例 | sync.Once或包级变量,避免手动 double-check | 高 |
| 创建型 | 工厂方法 | 创建者接口 + 返回接口的构造函数 | 高 |
| 创建型 | 抽象工厂 | 工厂接口返回一组相关产品接口 | 中 |
| 创建型 | 建造者 | Functional Options 或链式 Builder | 高 |
| 创建型 | 原型 | Clone 方法,浅拷贝用*,深拷贝序列化或手工复制 | 低 |
| 结构型 | 适配器 | 使用方定义小接口 + 包装结构体 | 中 |
| 结构型 | 桥接 | 结构体持有接口字段,代替多维继承 | 中 |
| 结构型 | 组合 | 统一 Node 接口,容器递归调用 | 中 |
| 结构型 | 装饰器 | 函数包装或接口包装,保持签名不变 | 高 |
| 结构型 | 外观 | Service 结构体聚合子系统,对外暴露精简方法 | 高 |
| 结构型 | 享元 | 缓存共享不可变对象,可用sync.Map | 低 |
| 结构型 | 代理 | 包装接口,控制访问、缓存、权限 | 中 |
| 行为型 | 责任链 | 中间件函数链,或节点链表 | 高 |
| 行为型 | 命令 | Command 接口或函数,支持队列/撤销 | 中 |
| 行为型 | 解释器 | 表达式接口 + 各节点结构体 | 低 |
| 行为型 | 迭代器 | 内置 range,自定义Next或 channel | 中 |
| 行为型 | 中介者 | 中央对象持有组件并协调通信 | 低 |
| 行为型 | 备忘录 | 快照结构体 + 深拷贝 | 低 |
| 行为型 | 观察者 | channel + 订阅表,注意退订与阻塞 | 高 |
| 行为型 | 状态 | 状态接口 + 上下文结构体,或map[State]func | 中 |
| 行为型 | 策略 | 函数参数或接口字段 | 高 |
| 行为型 | 模板方法 | sort.Interface风格,接口让人实现步骤 | 中 |
| 行为型 | 访问者 | 用类型断言 switch 替代经典双重分派 | 低 |
6. 实操心得:这套合集该怎么用,以及我踩过的坑
6.1 别为了写模式而写模式
我在代码评审里看到过很多“为了用设计模式而用”的代码。最常见的是单例,一个配置类明明没有状态,每次new也无所谓,非搞成全局单例,结果测试没法隔离。还有装饰器,本来一个函数写清楚逻辑就够了,非要套三四层 handler,最后看堆栈像剥洋葱。设计模式的目标是解决问题,不是证明你懂设计模式。我的原则是:先写直白的代码,等发现变化点、重复点、耦合点时,再考虑用某个模式重构。模式是重构的方向,不是一开始的模板。
6.2 Go 实现模式的四个经典坑
第一个是值接收者和指针接收者不统一。在工厂、组合、代理模式里,如果你把值类型塞进接口,每次调用方法都会隐式复制一份。小值类型没问题,但如果结构体里有锁、切片、或者方法需要修改自身,就必须用指针接收者。建议一个类型统一使用指针接收者,除非你能确保它是不可变值对象。
第二个是 nil 接口不等于 nil。这是 Go 里非常经典的陷阱:
var p *Config func Get() interface{} { return p } func main() { if Get() == nil { fmt.Println("nil") } else { fmt.Println("not nil") } }函数的返回值虽然底层是 nil 指针,但包在 interface 里后,interface 不为 nil。这在单例模式、工厂模式里尤其容易踩。所以返回接口前,务必确认底层值是否为 nil。
第三个是 channel 生命周期。观察者、迭代器、责任链如果用了 channel,一定要明确“谁创建谁关闭”。发布者不能向已关闭的 channel 发送,订阅者不消费会造成阻塞和 goroutine 泄漏。好的做法是给每个订阅者一个独立的退订信号,或者在设计事件总线时提供Unsubscribe(topic, ch)。
第四个是泛型出来后,不要无脑泛型化所有模式。Go 1.18 之后,有人把单例、工厂、缓存全部用泛型重写,看起来很炫,但可读性明显下降。泛型适合容器、通用算法,不适合业务语义复杂的模式。接口和函数依然是最常用的表达方式。
6.3 建议的学习路线
如果你刚从 C++/Java 过来,不要按 GoF 的目录顺序背。按工程出现频率排序,我建议先学这几个:单例、工厂方法、建造者、装饰器、策略、观察者、责任链。这七个在 Go 项目里出现频率最高,尤其是装饰器和责任链,Web 中间件天天都在用。第二梯队是适配器、代理、组合、状态、命令、外观、桥接、模板方法,这些遇到具体场景时查一下就能用。第三梯队是原型、享元、中介者、备忘录、访问者、解释器,主要用来读源码时能认出来,而不是用来写业务。
练习项目我推荐一个思路:写一个极简 HTTP 框架,把责任链用在中间件上、策略用在路由匹配上、模板方法用在框架骨架初始化上、装饰器用在日志和耗时统计上、观察者用在请求事件广播上。这一套练下来,你对模式在 Go 中的真实面貌会熟悉很多。
我个人的体会是,设计模式在 Go 里更像是一本“代码嗅觉训练手册”。它不告诉你必须怎么写,只告诉你哪些变化点值得被抽象。等你写多了,自然就会发现,真正有效的不是 23 个名字,而是“面向接口、组合复用、隔离变化”这三件事。用熟前面那批高频模式,剩下的遇到再翻,这套合集就算没白看。