面向对象的七大设计原则
大家公认的是,接口设计的五大核心原则(SOLID)。
本篇,除了包含(SOLID)。
除此之外,拓展了(迪米特原则 / 组合聚合)
目录
一、开闭原则(The Open-Closed Principle ,OCP)
二、 里式替换原则(Liskov Substitution Principle ,LSP)
三、 迪米特原则(最少知道原则)(Law of Demeter ,LoD)
四、单一职责原则(Single Responsibility Principle, SRP)
五、 接口分隔原则(Interface Segregation Principle ,ISP)
六、 依赖倒置原则(Dependency Inversion Principle ,DIP)
七、 组合/聚合复用原则(Composite/Aggregate Reuse Principle ,CARP)
七大原则之间并不是互相独立的,彼此之间存在一定关联。一个可能是另一个的加强或者是基础,违反其中的某一个,可能同时违法了其余原则。
开闭原则是面向对象的可复用设计的基石。其他设计原则是实现开闭原则的手段和工具。
一般地,可以把这七个原则分成了以下两个部分:
设计目标:开闭原则、里氏代换原则、迪米特原则
设计方法:单一职责原则、接口分隔原则、依赖倒置原则、组合/聚合复用原则
一、开闭原则(The Open-Closed Principle ,OCP)
软件实体(模块,类,方法等)应该对扩展开放,对修改关闭。
概念理解:
核心思想一句话: 对修改关闭,对扩展开放。 意思就是,当你要加新功能时,尽量别去改已经写好的、测试过的、正在运行的旧代码(改旧代码容易出错),而是通过添加新代码来实现。
为什么?为了:
- 稳如老狗 (稳定性):核心功能不动,系统就不容易崩。想象你在开车,引擎(核心代码)正跑得好好的,你突然想听新歌,难道要拆引擎盖接线吗?不!你直接用蓝牙连手机(扩展)就行。
- 灵活扩展 (扩展性):加新功能就像乐高积木,直接插新模块就行,不用把整个城堡拆了重搭。
- 好维护 (可维护性):别人(或者未来的你)看代码,旧逻辑很清晰,新功能都在新地方,找起来改起来都方便。
- 能复用 (可复用性):定义好的接口(规矩)大家都能用,不同的实现(具体干活的人)按规矩办事就行。
关键实现武器:接口 (Go 里的 interface)
什么是接口: 接口就是一份合同或者一份任务说明书。它只规定“做什么”(有哪些方法),但不规定“怎么做”。谁来干活(哪个具体的结构体)不管,只要你能按合同完成任务就行。
开闭原则的实现:
- 定义稳定的接口 (关上门):把系统中那些不变的、核心的操作抽象出来,写成接口。这个接口一旦定义好,就不要轻易改它(关闭修改)。
- 通过实现接口来扩展 (打开窗):当需要新功能时,就写一个新的结构体(类型),让它去实现这个接口。这样,新功能就通过添加新代码(新结构体)的方式加进来了(开放扩展)。
- 依赖接口,不依赖具体 (关键!):使用功能的地方(比如一个处理函数),它只认接口这个“合同”。它只管对着接口喊:“喂!那个谁(实现了接口的结构体),按合同干活!”。它根本不在乎具体是张三还是李四(哪个结构体)在干活。
案例剖析:
场景: 我们需要一个系统,能计算不同几何图形的面积。一开始只有矩形和圆形。
错误案例:
❌ 不符合开闭原则的设计 (直接依赖具体类 - 左边的设计):
package main import ( "fmt" "math" ) // 矩形结构体 type Rectangle struct { Width float64 Height float64 } // 矩形计算面积方法 func (r Rectangle) Area() float64 { return r.Width * r.Height } // 圆形结构体 type Circle struct { Radius float64 } // 圆形计算面积方法 func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius } // 计算总面积 (问题所在!) func TotalArea(shapes []interface{}) float64 { total := 0.0 for _, shape := range shapes { // 这里必须判断类型!很麻烦,而且每加一个新图形都要改这里! switch s := shape.(type) { case Rectangle: total += s.Area() case Circle: total += s.Area() // 如果增加三角形,需要在这里添加 case Triangle: ... } } return total } func main() { rect := Rectangle{Width: 10, Height: 5} circle := Circle{Radius: 7} shapes := []interface{}{rect, circle} area := TotalArea(shapes) fmt.Println("Total area:", area) }
问题在哪?
TotalArea函数直接依赖具体的Rectangle和Circle类型。- 当需要增加一个新的图形(比如
Triangle三角形)时:
- 你要定义
Triangle结构体和它的Area()方法。 - 你必须修改
TotalArea函数! 在里面增加case Triangle:分支。
- 违反了“对修改关闭”:为了加新功能(三角形),你不得不修改已经存在的、可能在其他地方也被调用的
TotalArea函数。这容易引入 Bug,也让代码越来越臃肿、脆弱。
正确示例:
package main import ( "fmt" "math" ) // 核心:定义稳定的接口 (合同) - "能计算面积的东西" type Shape interface { Area() float64 // 合同要求:必须有一个计算面积的方法 } // 矩形结构体 (实现者) type Rectangle struct { Width float64 Height float64 } // 矩形履行合同:实现Area方法 func (r Rectangle) Area() float64 { return r.Width * r.Height } // 圆形结构体 (实现者) type Circle struct { Radius float64 } // 圆形履行合同:实现Area方法 func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius } // 计算总面积 (关键!只依赖Shape接口) func TotalArea(shapes []Shape) float64 { // 参数是Shape接口的切片 total := 0.0 for _, shape := range shapes { // 这里不需要知道具体是矩形还是圆形!它只管调用合同规定的方法 Area() total += shape.Area() } return total } func main() { rect := Rectangle{Width: 10, Height: 5} circle := Circle{Radius: 7} // 注意:切片里的元素是 Shape 接口类型。Rectangle 和 Circle 都实现了 Shape,所以可以放进来。 shapes := []Shape{rect, circle} area := TotalArea(shapes) fmt.Println("Total area:", area) }
- 你做了什么? 你只添加了新代码 (
Triangle结构体和它的Area方法)。 - 你 没有 做什么? 你完全不需要修改
Shape接口的定义,也完全不需要修改TotalArea函数的任何一行代码! - 完美体现: 对修改关闭(
Shape接口、TotalArea函数没动),对扩展开放(通过新增Triangle实现Shape来扩展功能)。
总结:
- 开闭原则的精髓就是: 加新功能,别动老代码! 用添加代替修改。
- Go 里的法宝就是
interface: 定义好接口(规矩),让不同的结构体(干活的人)去实现它。 - 关键编程习惯: 写函数(比如
TotalArea)时,参数和内部操作尽量用接口类型 (Shape),而不是具体的结构体类型 (Rectangle,Circle)。这样函数就能自动适配任何未来实现了该接口的新类型。 - 好处大大的: 系统稳如泰山,加功能灵活如泥鳅,代码干净好维护,组件还能复用。这就是设计模式的魅力!
现实场景:
想象你写一个支付模块,定义好 PaymentProcessor 接口 (ProcessPayment(amount float64) error)。最开始有 AlipayProcessor 实现。后面要加微信支付?写个 WechatPayProcessor 实现同一个接口就行。调用支付的业务代码 (Checkout 函数) 完全不用改!这就是开闭原则在后端的威力。
二、 里式替换原则(Liskov Substitution Principle ,LSP)
所有引用基类的地方必须能透明地使用其派生类的对象。
概念理解:
换句话,反过来就是:子类型必须能够替换它们的基类型而不改变程序的正确性。
简称(不坑爹)
为什么这么重要?
- 防止继承滥用:不是两个类看起来有点像就能随便继承,关键看子类能不能完美替代父类
- 保证代码安全:避免出现"父类能用,子类一用就崩"的坑爹情况
- 支持开闭原则:只有满足LSP,才能安全地扩展子类而不改原有代码
关键要求(Go中的体现):
1、别用类型断言搞特殊对待:
// 违反LSP的写法 ❌ func Process(animal interface{}) { if dog, ok := animal.(Dog); ok { dog.Bark() } else if cat, ok := animal.(Cat); ok { cat.Meow() } // 每加一个新动物都要修改这里! }
换句话说:
- 如果程序接受父类型T
- 那么它应该不加修改地接受任何T的子类型S
- 不需要对S做任何特殊处理
2、子类必须完全实现父类承诺的功能:
- 不能削弱父类方法的功能(比如父类方法保证不返回错误,子类实现却可能返回错误)
- 不能加强前置条件(比如父类方法接受负数,子类却只接受正数)
- 不能削弱后置条件(比如父类方法保证返回非负数,子类实现却可能返回负数)
错误案例:
案例1:经典的正方形vs矩形(违反LSP)
package main import "fmt" // 矩形父类 type Rectangle struct { width, height float64 } func (r *Rectangle) SetWidth(w float64) { r.width = w } func (r *Rectangle) SetHeight(h float64) { r.height = h } func (r Rectangle) Area() float64 { return r.width * r.height } // 正方形子类 type Square struct { Rectangle // 内嵌模拟继承 } // 重写方法:设置宽时同时改高(保持正方形特性) func (s *Square) SetWidth(w float64) { s.width = w s.height = w // 这里埋雷了! } // 重写方法:设置高时同时改宽 func (s *Square) SetHeight(h float64) { s.height = h s.width = h // 这里埋雷了! } // 业务函数:调整矩形并验证面积 func ResizeAndVerify(rect *Rectangle) { rect.SetWidth(5) rect.SetHeight(4) expected := 20.0 actual := rect.Area() fmt.Printf("预期面积: %.1f, 实际面积: %.1f\n", expected, actual) if actual != expected { panic("面积验证失败!") } } func main() { rect := &Rectangle{} ResizeAndVerify(rect) //正常输出: 预期面积: 20.0, 实际面积: 20.0 // 尝试用正方形替代矩形 sq := &Square{} ResizeAndVerify(&sq.Rectangle) // 恐慌: 面积验证失败! // 实际输出: 预期面积: 20.0, 实际面积: 16.0 }
关键改进:
- 当
ResizeAndVerify调用SetHeight(4)时,正方形把宽也改成了4 - 导致最终面积是4x4=16,而不是预期的5x4=20
- 违反LSP:正方形不能完美替代矩形,导致程序行为异常
方案2:用组合替代继承(运动员案例)
// 自行车类 type Bike struct { color string } func (b *Bike) Move() { fmt.Println("自行车前进...") } func (b *Bike) Repair() { fmt.Println("修理自行车...") } // 运动员类(错误地继承自行车) type Athlete struct { Bike // 内嵌继承 strength int } func (a *Athlete) Train() { fmt.Printf("运动员训练,力量值: %d\n", a.strength) } // 使用自行车的函数 func ServiceBike(b *Bike) { fmt.Printf("正在服务%s的自行车...", b.color) b.Repair() } func main() { // 正确的使用 bike := &Bike{color: "红色"} ServiceBike(bike) // 正常服务 // 错误的使用:把运动员当自行车 athlete := &Athlete{Bike: Bike{color: "蓝色"}, strength: 80} ServiceBike(&athlete.Bike) // 语法可行,但逻辑荒谬! // 实际是修理了运动员的自行车,不是修理运动员 }
问题在哪?
- 语法上可行(通过内嵌字段),但语义上荒谬
- 运动员不是自行车(is-a关系不成立)
- 违反LSP:不能把运动员当作自行车使用
正确示例:
方案1:抽象出共同接口(四边形案例)
// 定义图形接口 type Shape interface { Area() float64 } // 矩形独立实现 type Rectangle struct{ w, h float64 } func (r Rectangle) Area() float64 { return r.w * r.h } func (r *Rectangle) SetDimensions(w, h float64) { r.w, r.h = w, h } // 正方形独立实现 type Square struct{ side float64 } func (s Square) Area() float64 { return s.side * s.side } func (s *Square) SetSide(length float64) { s.side = length } // 统一处理函数 func PrintArea(s Shape) { fmt.Printf("图形面积: %.1f\n", s.Area()) } func main() { rect := &Rectangle{} rect.SetDimensions(5, 4) PrintArea(rect) // 输出: 图形面积: 20.0 sq := &Square{} sq.SetSide(4) PrintArea(sq) // 输出: 图形面积: 16.0 }
- 通过
Shape接口抽象共同行为 - 矩形和正方形平级实现,不存在继承关系
- 各自暴露符合自身特性的方法(矩形用SetDimensions,正方形用SetSide)
方案2:用组合替代继承(运动员案例)
// 自行车类(不变) type Bike struct{ color string } // 运动员类(包含自行车) type Athlete struct { bike *Bike // 组合代替继承 strength int } func (a *Athlete) RideBike() { fmt.Printf("运动员骑行%s自行车...\n", a.bike.color) a.bike.Move() } func main() { athlete := &Athlete{ bike: &Bike{color: "黄色"}, strength: 90, } athlete.RideBike() // 运动员骑行黄色自行车... // 不再可能出现"把运动员当自行车修理"的逻辑错误 }
关键改进:
- 用"has-a"(拥有)关系替代"is-a"(是)关系
- 运动员包含自行车,而不是是自行车
- 符合现实世界的逻辑关系
切记组合优于继承:
// 当不确定时,优先用组合 type MyService struct { Logger *log.Logger // 组合logger DB *sql.DB // 组合数据库 }
里式替换原则的引申意义:子类可以扩展父类的功能,但不能改变父类原有的功能。
总结:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的方法时(重载/重写或实现抽象方法)的后置条件(即方法的输出/返回值)要比父类更严格或相等。(返回的子类型、或者是更具体的类型)
上述的一切都是在子类替换父类之后,程序运行仍然能正常运行。
>> 插一嘴: 继承之后能调用方法,属于方法提升
三、 迪米特原则(最少知道原则)(Law of Demeter ,LoD)
迪米特原则,可以简单说成:talk only to your immediate friends,只与你直接的朋友们通信,不要跟“陌生人”说话。
概念理解:
对于面向OOD来说,又被解释为下面两种方式:
1)一个软件实体应当尽可能少地与其他实体发生相互作用。
2)每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
什么是:“只有最少的知识” → 别打听 “无关细节” 一个单位不需要了解其他单位的 内部实现、间接依赖、非必要属性 ,只需要知道 “做什么”(接口 / 方法) ,而不是 “怎么做”(内部逻辑) 。
因此可以强化封装什么的....
以下举个例子:
想象你在公司里:
- 你是后端工程师(一个对象)
- 你的直系朋友:产品经理(参数传入)、数据库(字段引用)、日志系统(创建的对象)
- 陌生人:前端同事、测试同事、运维同事
迪米特原则要求:你只能找直系朋友办事:
- 直接问产品经理需求(方法参数)
- 直接操作数据库(字段引用)
- 直接写日志(创建的对象)
禁止:
➔ 别直接找前端要数据(陌生人)
➔ 别直接命令测试改用例(陌生人)
➔ 别直接让运维重启服务器(陌生人)
错误案例:
违反原则的Go代码(反面教材):
package main type DB struct{} func (db *DB) Query() string { return "用户数据" } // 日志记录器 type Logger struct{} func (l *Logger) Record(log string) { fmt.Println("记录日志:", log) } // 用户服务 - 违反迪米特原则! type UserService struct { logger *Logger } func (s *UserService) GetUserInfo(db *DB) { // 问题1:直接操作DB(不是直系朋友) data := db.Query() // 问题2:知道太多细节(需要组装日志) log := fmt.Sprintf("查询用户: %s", data) // 问题3:直接操作Logger(虽然是朋友,但过度暴露细节) s.logger.Record(log) } func main() { db := &DB{} logger := &Logger{} service := &UserService{logger: logger} service.GetUserInfo(db) }
问题分析:
UserService直接操作DB对象(DB不是它的字段,也不是参数)- 它知道太多业务细节(需要组装日志内容)
- 虽然
Logger是朋友,但直接调用其方法暴露了实现细节
正确示例:
package main import "fmt" // 数据访问层(抽象) type UserRepository interface { GetUser() string } // DB实现 type DB struct{} func (db *DB) GetUser() string { return "用户数据" } // 日志接口(抽象) type Logger interface { Info(msg string) } // 日志实现 type AppLogger struct{} func (l *AppLogger) Info(msg string) { fmt.Println("[INFO]", msg) } // 用户服务 - 符合迪米特! type UserService struct { repo UserRepository // 通过接口依赖 logger Logger // 通过接口依赖 } // 通过构造函数注入依赖 func NewUserService(repo UserRepository, logger Logger) *UserService { return &UserService{repo: repo, logger: logger} } func (s *UserService) GetUserInfo() { // 只跟直系朋友(repo)打交道 data := s.repo.GetUser() // 只跟直系朋友(logger)打交道 s.logger.Info(fmt.Sprintf("查询用户: %s", data)) } func main() { // 依赖组装(在高层模块) db := &DB{} logger := &AppLogger{} service := NewUserService(db, logger) service.GetUserInfo() }
遵循迪米特原则,你的Go代码会像组织良好的团队一样:职责清晰、协作高效、维护轻松!
四、单一职责原则(Single Responsibility Principle, SRP)
永远不要让一个类存在多个改变的理由。
概念理解:
核心思想一句话:
一个模块只干一件事! 就像餐厅里:
- 厨师只管做饭
- 服务员只管上菜
- 收银员只管结账
为什么重要?
- 改菜单不影响结账:修改厨师的工作不会影响收银系统
- 换人不乱套:新招一个配菜工,不会干扰服务员
- 修东西不牵连:收银机坏了只需要修收银模块
错误案例:
// 这个类既管用户又管订单还管日志,简直是"上帝类" type UserService struct { db *sql.DB logger *log.Logger } func (s *UserService) ProcessUser(userID int) { // 1. 查询用户(用户管理职责) user := s.db.QueryRow("SELECT * FROM users WHERE id = ?", userID) // 2. 创建订单(订单管理职责) orderID := createOrder(user) // 3. 记录日志(日志管理职责) s.logger.Println("创建订单:", orderID) // 4. 发送通知(通知职责) sendEmail(user.Email, "订单创建成功") }
问题在哪?
- 这个类同时负责:
- 用户管理
- 订单管理
- 日志记录
- 邮件通知
- 修改其中任何一部分都可能影响其他功能
- 测试时要模拟所有依赖,极其困难
正确示例:
// 职责1:只负责用户数据 type UserRepository struct { db *sql.DB } func (r *UserRepository) GetUser(userID int) (*User, error) { // 仅用户查询逻辑 } // 职责2:只负责订单管理 type OrderService struct{} func (s *OrderService) CreateOrder(user *User) (int, error) { // 仅订单创建逻辑 } // 职责3:只负责日志记录 type Logger interface { Log(message string) } // 职责4:只负责通知 type Notifier interface { Notify(email, message string) } // 协调层(组合各单一职责组件) type UserProcessor struct { userRepo *UserRepository orderSvc *OrderService logger Logger notifier Notifier } func (p *UserProcessor) Process(userID int) { // 1. 获取用户(调用单一职责组件) user, _ := p.userRepo.GetUser(userID) // 2. 创建订单(调用单一职责组件) orderID, _ := p.orderSvc.CreateOrder(user) // 3. 记录日志(调用单一职责组件) p.logger.Log(fmt.Sprintf("创建订单: %d", orderID)) // 4. 发送通知(调用单一职责组件) p.notifier.Notify(user.Email, "订单创建成功") }
记得:接口最小化,职责单一。
Robert.C Martin给出了一个著名的定义:所谓一个类的一个职责是指引起该类变化的一个原因。
如果你能想到一个类存在多个使其改变的原因,那么这个类就存在多个职责。
五、 接口分隔原则(Interface Segregation Principle ,ISP)
概念理解:
核心思想一句话:
别逼用户接没用的功能! 就像买手机:
- 打电话用户:不需要强行搭配游戏手柄
- 游戏用户:不需要强行搭配老人模式
为什么重要?
- 避免被强塞垃圾:用户不会被强迫实现不需要的方法
- 改功能不误伤:修改游戏功能不会影响打电话用户
- 接口清爽好用:每个接口都短小精悍,一目了然
错误案例:
门禁系统:
// 错误:臃肿的万能接口 type SuperDoor interface { Lock() // 锁门 Unlock() // 解锁 AlarmOn() // 打开警报 AlarmOff() // 关闭警报 FingerprintAuth() // 指纹验证 FaceAuth() // 人脸识别 } // 普通门实现(被迫实现不需要的方法) type BasicDoor struct{} func (d *BasicDoor) Lock() { fmt.Println("上锁") } func (d *BasicDoor) Unlock() { fmt.Println("解锁") } // 普通门根本不需要这些功能! func (d *BasicDoor) AlarmOn() { panic("不支持!") } func (d *BasicDoor) AlarmOff() { panic("不支持!") } func (d *BasicDoor) FingerprintAuth() { panic("不支持!") } func (d *BasicDoor) FaceAuth() { panic("不支持!") } // 使用普通门 func main() { var door SuperDoor = &BasicDoor{} door.Lock() // 正常 door.AlarmOn() // 运行时panic! }
问题在哪?
- 普通门被强迫实现警报/生物识别
- 调用不存在的方法会导致运行时崩溃
- 添加新功能时所有门都要修改
正确示例:
// 拆分成最小功能接口 // 基础门接口 type BasicDoor interface { Lock() Unlock() } // 警报功能接口 type Alarmer interface { AlarmOn() AlarmOff() } // 生物识别接口 type BiometricAuth interface { FingerprintAuth() FaceAuth() } // 普通门实现 type SimpleDoor struct{} func (d *SimpleDoor) Lock() { fmt.Println("机械锁上锁") } func (d *SimpleDoor) Unlock() { fmt.Println("机械锁解锁") } // 智能门实现 type SmartDoor struct { BasicDoor // 嵌入基础功能 Alarmer // 组合警报功能 BiometricAuth // 组合生物识别 } // 实现警报功能 func (d *SmartDoor) AlarmOn() { fmt.Println("警报启动") } func (d *SmartDoor) AlarmOff() { fmt.Println("警报关闭") } // 实现生物识别 func (d *SmartDoor) FingerprintAuth() { fmt.Println("指纹验证通过") } func (d *SmartDoor) FaceAuth() { fmt.Println("人脸识别通过") } // 使用场景 func SecureAreaAccess(door Alarmer) { door.AlarmOff() // 只需警报功能 defer door.AlarmOn() fmt.Println("进入安全区域...") } func main() { // 普通门用户 simpleDoor := &SimpleDoor{} simpleDoor.Lock() // 只使用需要的方法 // 智能门用户 smartDoor := &SmartDoor{} smartDoor.FaceAuth() // 使用高级功能 // 安全系统只关心警报 SecureAreaAccess(smartDoor) // 传入Alarmer接口 // SecureAreaAccess(simpleDoor) // 编译直接报错(类型安全) }
重构要点:
- 拆!拆!拆!:
BasicDoor:基础开关Alarmer:警报功能BiometricAuth:生物识别
- 按需组合:
- 普通门只实现基础功能
- 智能门组合多个接口
- 编译时保护:
- 试图把普通门当警报器用?编译直接失败!
- 不需要等到运行时崩溃
重点:
在单一职责原则上,加上这句话:不能强迫用户去依赖那些他们不使用的接口。
换句话说,使用多个专门的接口比使用单一的总接口总要好。
总而言之,接口分隔原则指导我们:
- 一个类对一个类的依赖应该建立在最小的接口上
- 建立单一接口,不要建立庞大臃肿的接口
- 尽量细化接口,接口中的方法尽量少
六、 依赖倒置原则(Dependency Inversion Principle ,DIP)
概念理解:
核心思想一句话:
高层定规矩,底层去实现! 就像老板定战略(高层),员工去执行(底层),老板不需要知道员工具体怎么做。
为什么重要?
- 换员工不影响战略:更换数据库不影响业务逻辑
- 老板更专注:高层只关心做什么,不关心怎么做
- 系统更灵活:底层实现可以随意替换
- 测试更容易:可以用模拟对象代替真实数据库
错误案例:
违反DIP的Go代码(反面教材)
// 低层模块:MySQL数据库操作 type MySQLDatabase struct{} func (db *MySQLDatabase) Query(query string) string { fmt.Println("执行MySQL查询:", query) return "MySQL查询结果" } // 高层模块:业务逻辑(直接依赖低层实现) type ReportService struct { db *MySQLDatabase // 直接依赖具体实现 } func (s *ReportService) GenerateReport() { result := s.db.Query("SELECT * FROM reports") fmt.Println("生成报告:", result) } func main() { service := &ReportService{db: &MySQLDatabase{}} service.GenerateReport() // 如果想换成PostgreSQL?必须修改ReportService代码! }
问题在哪?
- 高层
ReportService直接依赖低层MySQLDatabase - 更换数据库需要修改业务逻辑
- 单元测试需要真实MySQL连接
正确示例:
// 1. 定义抽象接口(高层制定规则) type Database interface { Query(string) string } // 2. 高层模块依赖抽象接口 type ReportService struct { db Database // 依赖抽象,不依赖具体实现 } func (s *ReportService) GenerateReport() { result := s.db.Query("SELECT * FROM reports") fmt.Println("生成报告:", result) } // 3. 低层模块实现接口 type MySQLDatabase struct{} func (db *MySQLDatabase) Query(query string) string { fmt.Println("执行MySQL查询:", query) return "MySQL查询结果" } // 新增PostgreSQL实现(低层模块) type PostgreSQLDatabase struct{} func (db *PostgreSQLDatabase) Query(query string) string { fmt.Println("执行PostgreSQL查询:", query) return "PostgreSQL查询结果" } // 4. 依赖注入(在程序入口组装) func main() { // 使用MySQL mysqlService := &ReportService{db: &MySQLDatabase{}} mysqlService.GenerateReport() // 切换到PostgreSQL只需改这里 pgService := &ReportService{db: &PostgreSQLDatabase{}} pgService.GenerateReport() // 单元测试可以用Mock mockService := &ReportService{db: &MockDatabase{}} mockService.GenerateReport() } // Mock实现(用于测试) type MockDatabase struct{} func (db *MockDatabase) Query(query string) string { return "模拟数据" }
重构要点:
- 高层定接口:
ReportService定义它需要什么功能 - 低层实现接口:MySQL/PostgreSQL实现这个接口
- 依赖注入:在main函数中"注入"具体实现
- 面向接口编程:所有代码都依赖接口而非具体实现
这就是精髓所在!!!
这就是依赖注入,多优美啊。真帅!
请切记以下三条:
A. 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
B. 抽象不应该依赖于细节,细节应该依赖于抽象
C.针对接口编程,不要针对实现编程。
这是我整过来的两张图:
普通的直接依赖:
依赖倒置:
问题引申:
我之前,一直对依赖注入有很多疑问,在这里逐步解决问题。
依赖注入与接口抽象的价值分析
你提出的问题非常关键——为什么需要这些看似"多余"的接口抽象和依赖注入?让我们深入分析这个设计模式的真正价值。
看似多余的代码?
type UserService struct { repo UserRepository // 接口类型 logger Logger // 接口类型 } func NewUserService(repo UserRepository, logger Logger) *UserService { return &UserService{repo: repo, logger: logger} }
表面上,这确实比直接使用具体类型更复杂:
- 需要定义接口
- 需要写构造函数
- 需要额外注入依赖
但这绝不是多此一举! 这些设计提供了重要的软件工程优势:
一、场景 1:切换数据库实现(MySQL → MongoDB)
假设原有代码直接依赖 MySQL 数据库,现在需要切换为 MongoDB,通过接口抽象 + 依赖注入实现无缝切换。
步骤 1:定义抽象接口
首先定义数据访问层的接口 UserRepository,抽象所有数据库操作:
// 定义用户仓储接口(抽象契约) type UserRepository interface { Save(user User) error FindByID(id string) (*User, error) } // 用户实体 type User struct { ID string Username string Email string }
步骤 2:实现不同数据库的具体类
分别实现 MySQL 和 MongoDB 的具体仓储:
// MySQL 实现 type MySQLUserRepository struct { db *sql.DB // 实际项目中需初始化数据库连接 } func (r *MySQLUserRepository) Save(user User) error { // MySQL 特有的 SQL 逻辑 _, err := r.db.Exec("INSERT INTO users (id, username, email) VALUES (?, ?, ?)", user.ID, user.Username, user.Email) return err } func (r *MySQLUserRepository) FindByID(id string) (*User, error) { // MySQL 查询逻辑 var user User err := r.db.QueryRow("SELECT id, username, email FROM users WHERE id = ?", id). Scan(&user.ID, &user.Username, &user.Email) if err != nil { return nil, err } return &user, nil } // MongoDB 实现 type MongoDBUserRepository struct { client *mongo.Client // MongoDB 客户端 collection *mongo.Collection } func (r *MongoDBUserRepository) Save(user User) error { // MongoDB 特有的文档插入逻辑 _, err := r.collection.InsertOne(context.Background(), user) return err } func (r *MongoDBUserRepository) FindByID(id string) (*User, error) { // MongoDB 查询逻辑 var user User err := r.collection.FindOne(context.Background(), bson.M{"id": id}).Decode(&user) if err != nil { return nil, err } return &user, nil }
步骤 3:业务逻辑层依赖接口
UserService 仅依赖 UserRepository 接口,不关心具体数据库实现:
type UserService struct { repo UserRepository // 依赖抽象接口,而非具体实现 } func NewUserService(repo UserRepository) *UserService { return &UserService{repo: repo} } func (s *UserService) Register(user User) error { // 业务逻辑(如参数校验、密码加密等) err := s.repo.Save(user) // 调用抽象接口,无数据库细节 if err != nil { return fmt.Errorf("注册失败: %w", err) } return nil }
步骤 4:运行时动态切换实现
通过依赖注入在初始化时选择具体实现:
func main() { // 1. 初始化数据库连接(根据配置选择 MySQL 或 MongoDB) var repo UserRepository dbType := os.Getenv("DB_TYPE") // 从环境变量读取配置 if dbType == "mongodb" { // 初始化 MongoDB 客户端和仓储 client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://localhost:27017")) if err != nil { log.Fatal(err) } collection := client.Database("demo").Collection("users") repo = &MongoDBUserRepository{client: client, collection: collection} } else { // 默认使用 MySQL db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/demo") if err != nil { log.Fatal(err) } repo = &MySQLUserRepository{db: db} } // 2. 注入仓储到 UserService userService := NewUserService(repo) // 3. 调用业务逻辑 user := User{ID: "123", Username: "Alice", Email: "alice@example.com"} err := userService.Register(user) if err != nil { log.Fatal(err) } }
七、 组合/聚合复用原则(Composite/Aggregate Reuse Principle ,CARP)
尽量使用组合/聚合,不要使用类继承。
用衣服与心脏与人来举例组合与聚合的区别,我认为会非常棒。
由于衣服可以单独存在,所有与人进行合并后叫聚合。
而人离不开心脏,同样心脏也离不开人。他们彼此之间无法分开,故为组合。
概念理解:
核心思想一句话:
能组装就别继承! 就像造车:
- 发动机是买来的(组合)
- 轮胎是采购的(聚合)
- 而不是让车厂自己"生"出发动机(继承)
为什么重要?
| 方式 | 优点 | 缺点 |
| 组合/聚合 | 灵活更换零件 降低耦合 运行时动态调整 支持多种功能组合 |
需要管理更多对象 |
| 继承 | 代码复用简单 修改父类自动影响子类 |
破坏封装性 父类改动影响所有子类 灵活性差 容易导致类爆炸 |
组合:
示例:汽车(Car)和发动机(Engine)
发动机是汽车的核心组件,无法脱离汽车独立存在(汽车销毁时,发动机也随之 "失效")。
package main import "fmt" // 发动机(部分) type Engine struct { horsepower int } func NewEngine(horsepower int) *Engine { fmt.Println("发动机被制造") return &Engine{horsepower: horsepower} } // 汽车(整体) type Car struct { brand string engine *Engine // 组合:汽车"包含"发动机,发动机的生命周期由汽车控制 } func NewCar(brand string, horsepower int) *Car { // 汽车创建时,必须同时创建发动机(强依赖) return &Car{ brand: brand, engine: NewEngine(horsepower), } } func (c *Car) Run() { fmt.Printf("%s汽车(%d马力)启动\n", c.brand, c.engine.horsepower) } func main() { // 创建汽车时,自动创建发动机 car := NewCar("特斯拉", 300) car.Run() // 当car被垃圾回收时,其内部的engine也会被一同回收 }
聚合:
示例:公司(Company)和员工(Employee)
员工可以属于公司,但员工的存在不依赖于公司(公司倒闭后,员工仍可独立存在)。
package main import "fmt" // 员工(部分) type Employee struct { name string } func NewEmployee(name string) *Employee { return &Employee{name: name} } // 公司(整体) type Company struct { name string employees []*Employee // 聚合:公司"关联"员工,员工可独立存在 } func NewCompany(name string) *Company { return &Company{ name: name, } } // 公司招聘员工(关联已有员工) func (c *Company) Hire(e *Employee) { c.employees = append(c.employees, e) } func (c *Company) ShowStaff() { fmt.Printf("%s公司的员工:\n", c.name) for _, emp := range c.employees { fmt.Printf("- %s\n", emp.name) } } func main() { // 先创建独立的员工(不依赖公司) emp1 := NewEmployee("张三") emp2 := NewEmployee("李四") // 再创建公司,关联已有的员工 company := NewCompany("字节跳动") company.Hire(emp1) company.Hire(emp2) company.ShowStaff() // 即使company被销毁,emp1和emp2仍可被其他对象引用 }
- 组合:
Car创建时必须同时创建Engine,两者生命周期完全绑定(Engine无法独立于Car存在)。 - 聚合:
Company可以关联已存在的Employee,Employee可独立于Company存在(可被多个Company共享)。
错误案例:
// 错误:用继承实现角色系统 type Person struct { Name string } // 雇员继承"人" type Employee struct { Person Company string } // 学生继承"人" type Student struct { Person School string } func main() { // 张三既是雇员又是学生? emp := Employee{Person{"张三"}, "腾讯"} stu := Student{Person{"张三"}, "清华"} // 同一个张三被分裂成两个对象! }
问题在哪?
- 现实中一个人可以同时是雇员和学生
- 使用继承导致角色分裂
- 添加新角色(如父亲)需要新建类型
- 违反现实世界的逻辑关系
正确示例:
// 正确:用组合实现角色 // 基础人类型 type Person struct { Name string Roles []Role // 聚合多个角色 } // 角色接口 type Role interface { Describe() string } // 雇员角色 type EmployeeRole struct { Company string } func (r EmployeeRole) Describe() string { return fmt.Sprintf("雇员@%s", r.Company) } // 学生角色 type StudentRole struct { School string } func (r StudentRole) Describe() string { return fmt.Sprintf("学生@%s", r.School) } // 父亲角色 type ParentRole struct { Children int } func (r ParentRole) Describe() string { return fmt.Sprintf("父亲(%d孩)", r.Children) } func main() { // 张三有多个角色 zhangsan := Person{ Name: "张三", Roles: []Role{ EmployeeRole{"腾讯"}, StudentRole{"清华夜校"}, ParentRole{Children: 2}, }, } // 展示所有角色 for _, role := range zhangsan.Roles { fmt.Println(role.Describe()) } // 运行时移除学生角色 zhangsan.Roles = zhangsan.Roles[:2] }
重构要点:
- 角色独立存在:每个角色实现统一接口
- 人组合角色:通过切片聚合多个角色
- 动态增删:运行时添加/移除角色
- 符合现实:一个人可同时有多个身份
接口设计的五大核心原则(SOLID)
SOLID 是五个原则的首字母缩写,具体包括:
- 单一职责原则(Single Responsibility Principle, SRP)
一个接口或类应该只负责一项职责,即仅有一个引起它变化的原因。
例如:用户接口应仅处理用户信息相关操作(注册、登录),不应同时包含订单处理逻辑。 - 开放 - 封闭原则(Open-Closed Principle, OCP)
接口或类应对扩展开放,对修改关闭。即通过扩展现有接口来新增功能,而非修改原有代码。
例如:设计支付接口时,预留扩展点支持新增支付方式(如从支付宝扩展到微信支付),无需修改原有接口逻辑。 - 里氏替换原则(Liskov Substitution Principle, LSP)
子类必须能够替换其基类(或实现类必须能替换接口),且不影响程序正确性。
例如:实现 “形状” 接口的 “正方形” 和 “圆形”,在计算面积时应符合接口预期,不能出现逻辑冲突。 - 接口隔离原则(Interface Segregation Principle, ISP)
不应强迫客户端依赖其不需要的接口方法,应将大接口拆分为多个小而专的接口。
例如:“动物” 接口不应包含 “飞” 的方法,而应拆分为 “会飞的动物”“会跑的动物” 等细分接口,避免不会飞的动物被迫实现该方法。 - 依赖倒置原则(Dependency Inversion Principle, DIP)
高层模块不应依赖低层模块,两者都应依赖抽象(接口);抽象不应依赖细节,细节应依赖抽象。
例如:业务逻辑层(高层)应依赖 “数据存储接口”,而非直接依赖 MySQL(低层实现),这样切换数据库时无需修改高层代码。
借鉴博客: