整理golang七大设计原则

1,302 阅读23分钟

开闭原则

开闭原则是设计模式中的总原则,开闭原则就是说:对拓展开放、对修改关闭。

模块应该在尽不修改代码的前提下进行拓展,这就需要使用接口和抽象类来实现预期效果。 我们举例说明什么是开闭原则,以4s店销售汽车为例,其类图如图所示:

ICar接口定义了汽车的两个属性:名称和价格。BenzCar是一个奔驰车的实现类,代表奔驰车的总称。Shop4S代表售卖的4S店,ICar接口的代码清单如下

type ICar interface {  
 // 车名  
 GetName() string
 // 价格  
 GetPrice() int
}

一般情况下4S店只出售一种品牌的车,这里用奔驰为例,代码清单如下

type BenzCar struct {
    name string
    price int
}
​
func (b BenzCar) GetName() string {
    return b.name
}
​
func (b BenzCar) GetPrice() int {
    return b.price
}

这里我们模拟一下4s店售车记录

func TestBenzCar_GetName(t *testing.T) {
    var (
        list []ICar
    )
    list = []ICar{}
    list = append(list,&BenzCar{"迈巴赫",130})
    list = append(list,&BenzCar{"AMG",343})
    list = append(list,&BenzCar{"V",60})
    for _,v := range list {
        fmt.Println("车名:",v.GetName(),"\t价格:",v.GetPrice())
    }
}

暂时来看,以上设计是没有啥问题的。但是,某一天,4s店老板说奔驰轿车统一要收取一笔金融服务费,收取规则是价格在100万元以上的收取5%,50~100万元的收取2%,其余不收.取。为了应对这种需求变化,之前的设计又该如何呢? 目前,解决方案大致有如下三种: ● 修改ICar接口:在ICar接口上加一个getPriceWithFinance接口,专门获取加上金融服务费后的价格信息。这样的后果是,实现类BenzCar也要修改,业务类Shop4S也要做相应调整。ICar 接口一般应该是足够稳定的,不应频繁修改,否则就失去了接口锲约性了。

● 修改BenzCar实现类:直接修改BenzCar类的getPrice方法,添加金融服务费的处理。这样的一个直接后果就是,之前依赖getPrice的业务模块的业务逻辑就发生了改变了,price也不是之前的price了。

● 使用子类拓展来实现:增加子类FinanceBenzCar,覆写父类BenzCar的getPrice方法,实现金融服务费相关逻辑处理。这样的好处是:只需要调整Shop4S中的静态模块区中的代码,main中的逻辑是不用做任何修改的。

修改后的FinanceBenzCar类代码清单如下:

type FinanceBenzCar struct {
    BenzCar
}
​
func (b FinanceBenzCar) GetPrice() int {
    // 获取原价
    selfPrice := b.price
    var finance int
    if selfPrice >= 100 {
        finance = selfPrice + selfPrice*5/100
    } else if selfPrice >= 50 {
        finance = selfPrice + selfPrice*2/100
    } else {
        finance = selfPrice
    }
    return finance
}

测试类

func TestBenzCar_GetName(t *testing.T) {
    var (
        list []ICar
    )
    list = []ICar{}
    list = append(list,&FinanceBenzCar{BenzCar{"迈巴赫",99}})
    list = append(list,&FinanceBenzCar{BenzCar{"AMG",200}})
    list = append(list,&FinanceBenzCar{BenzCar{"V",40}})
    for _,v := range list {
        fmt.Println("车名:",v.GetName(),"\t价格:",v.GetPrice())
    }
}

测试结果

=== RUN   TestBenzCar_GetName
车名: 迈巴赫     价格: 100
车名: AMG     价格: 210
车名: V   价格: 40
--- PASS: TestBenzCar_GetName (0.00s)
PASS

这样,在业务规则发生改变的情况下,我们通过拓展子类及修改持久层(高层次模块)便足以应对多变的需求。开闭原则要求我们尽可能通过拓展来实现变化,尽可能少地改变已有模块,特别是底层模块。

开闭原则总结: ● 提高代码复用性 ● 提高代码的可维护性

单一职责原则

单一职责原则,简单来说就是保证设计类、接口、方法时做到 功能单一,权责明确。比如:

这里我们定义“更新用户” 的接口, 倘若有一天新来的前端要求加一个修改用户密码的接口,后端直接说:”你去调 updateUser”接口吧,传入密码信息就行。这种后端往往不是太懒就是新手,updateUser 接口的粒度太粗,接口职责不够单一,所以应该将接口拆分为各个细分接口,比如修改如下:

这里很明显,我们看到分拆后的接口职责更加单一, 权责更加清楚,日后维护开发也更加便捷。

单一职责原则,指的是一个类或者模块有只有一 个改变的原因。 如果模块或类承担的职责过多,就等于这些职责耦合在一起, 这样一个模块的变快可能会削弱或抑制其它模块的能力, 这样的耦合是十分脆弱地。所以应该尽量保持单一-职责原则, 此原则的核心就是解耦和增强内聚性。

在现在流行的微服务架构体系中,最头疼的就是服务拆分,拆分的粒度也很有讲究,标准的应该是遵从单一原则,避免服务拆分时发生各种撕逼行为:”本应该在A服务中的被安排在了B服务中", 所以服务的职责划分尤为重要。

再有就是,做service层开发时,早期的开发人员会将数据库操作放在service 中,比如getConnection,然后执行prepareStatement,再就是service逻辑处理等等。可是后来发现.数据库要由原来的mysql变更为oracle, service 层代码岂不是需要重写一遍,天了...直接崩溃跑路。

”我单纯,所以我快乐“用来形容单-职责原则再恰当不过了。

单职责原则总结:单一职责可以降低类的复杂性,提高代码可读性、可维护性但是用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因目、环境而异;指责划分稍微不当,很容易造成资源浪费,代码量增多,好比微服务时服务边界拆分不清

package main
​
import (
    "log"
)
​
// IUserBo 只负责用户属性的接口
type IUserBo interface{
    // 获取用户的Id
    GetUserID()
    // 获取用户密码
    GetUserPW()
}
​
// IUserBiz 只负责用户信息的维护接口
type IUserBiz interface{
    // 添加用户
    AddUser()
    // 删除用户
    RemoveUser()
}
​
// User 用户属性
type User struct{
    ID string
    PW string
}
​
// Users 管理用户
var Users = make([]*User,0)
​
// GetUserID 获取用户ID
func(u *User)GetUserID(){
    log.Println(u.ID)
}
​
// GetUserPW 获取用户的密码
func(u *User)GetUserPW(){
    log.Println(u.PW)
}
​
// AddUser 添加用户
func(u *User)AddUser(){
    for _,v := range Users{
        if u.ID == v.ID{
            log.Println("该用户已经存在")
            return
        }
    }
    Users = append(Users,u)
}
​
// RemoveUser 删除用户
func (u *User)RemoveUser(){
    for i:=0;i<len(Users);i++{
        if(Users[i].ID==u.ID){
            // golang的数组删除元素就是这么的简单
            Users = append(Users[:i],Users[i+1:]...)
        }
    }
    log.Println(Users)
}
​
func init(){
    log.SetFlags(log.Ldate|log.Lshortfile)
    u1 := &User{"1","11"}
    u2 := &User{"2","12"}
    u3 := &User{"3","13"}
    u4 := &User{"4","14"}
    u5 := &User{"5","15"}
    Users = append(Users,u1,u2,u3,u4,u5)
}
​
​
func main(){
    var iso IUserBo
    var isz IUserBiz
​
    u := &User{"1","11"}
    iso = u
    iso.GetUserID()
    iso.GetUserPW()
​
    isz = u
    isz.AddUser()
    isz.RemoveUser()
}

里式替换原则

里式替换原则的解释是,所有引用基类的地方必须能透明地使用其子类的对象。 通俗来讲的话,就是说,只要父类能出现的地方子类就可以出现,并且使用子类替换掉父类的话,不会产性任何异常或错误,使用者可能根本就不需要知道是父类还是子类。反过来就不行了,有子类的地方不定能使用父类替换。

比如某个方法接受一个 Map型参数,那么它一定可以接受HashMap、LinkedHashMap 等参数,但是反过来的话,一个接受HashMap的方法不一定能接受所有Map类型参数。

里式替换原则是开闭原则的实现基础,它告诉我们设计程序的时候尽可能使用基类进行对象的定义及引用,具体运行时再决定基类对应的具体子类型。

接下来举个栗子,我们定义一个抽象类 AbstractAnimal 对象,该对象声明内部方法” 跳舞”,其中,Rabbit、Dog、 Lion 分别继承该对象,另外声明-一个Person类,该类负责喂养各种动物,Client 类负责逻辑调用,类图如下:

其中,Person类的代码如下:

type Person struct {
    ani Animal
}
​
func (p Person) WalkAnimal() {
    fmt.Println("人开始溜动物")
    p.ani.dance()
}

主函数的调用如下

person := Person{ani:&Rubbit{}}  
person.WalkAnimal()

结果:

人开始溜动物
小白兔跳舞

这里,Person 类中本该出现的父类AbstractAnimal我们运行时使用具体子类代替,只要是父类能出现的地方子类就能出现,这就要求我们模块设计时尽量以基类进行对象的定义及应用。

里氏替换原则总结: ● 里氏替换可以提高代码复用性,子类继承父类时自然继承到了父类的属性和方法 ● 提高代码可拓展性,子类通过实现父类方法进行功能拓展,个性化定制 ● 里氏替换中的继承有侵入性。继承,就必然拥有父类的属性和方法 ● 增加了代码的耦合性。父类方法或属性的更改,要考虑子类所引发的变更

# IGoodBird.go
# 更好的设计.
# IGoodBird仅定义了最基本的方法集, 通过子接口IFlyableBird添加Fly方法, 通过子接口IRunnableBird添加Run方法
package main
​
type IGoodBird interface {
    ID() int
    Name() string
    Tweet() error
}
​
type IFlyableBird interface {
    IGoodBird
    Fly() error
}
​
type IRunnableBird interface {
    IGoodBird
    Run() error
}
# GoodNormalBird.go
# GoodNormalBird提供对IGoodBird的基础实现
package main
​
import "fmt"type GoodNormalBird struct {
    iID int
    sName string
}
​
func NewGoodNormalBird(id int, name string) *GoodNormalBird {
    return &GoodNormalBird{
        id,
        name,
    }
}
​
func (me *GoodNormalBird) ID() int {
    return me.iID
}
​
func (me *GoodNormalBird) Name() string {
    return me.sName
}
​
func (me *GoodNormalBird) Tweet() error {
    fmt.Printf("GoodNormalBird.Tweet, id=%v, name=%v\n", me.ID(), me.Name())
    return nil
}
# GoodFlyableBird.go
# GoodFlyableBird通过聚合GoodNormalBird实现IGoodBird接口, 通过提供Fly方法实现IFlyableBird子接口
package main
​
import "fmt"type GoodFlyableBird struct {
    GoodNormalBird
}
​
func NewGoodFlyableBird(id int, name string) IGoodBird {
    return &GoodFlyableBird{
        *NewGoodNormalBird(id, name),
    }
}
​
func (me *GoodFlyableBird) Fly() error {
    fmt.Printf("GoodFlyableBird.Fly, id=%v, name=%v\n", me.ID(), me.Name())
    return nil
}
# GoodOstrichBird.go
# GoodOstrichBird通过聚合GoodNormalBird实现IGoodBird接口, 通过提供Run方法实现IRunnableBird子接口
package main
​
import (
    "fmt"
)
​
type GoodOstrichBird struct {
    GoodNormalBird
}
​
func NewGoodOstrichBird(id int, name string) IGoodBird {
    return &GoodOstrichBird{
        *NewGoodNormalBird(id, name),
    }
}
​
func (me *GoodOstrichBird) Run() error {
    fmt.Printf("GoodOstrichBird.Run, id=%v, name=%v\n", me.ID(), me.Name())
    return nil
}

里氏替换原则的主要作用如下。

  1. 里氏替换原则是实现开闭原则的重要方式之一。
  2. 它克服了继承中重写父类造成的可复用性变差的缺点。
  3. 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。
  4. 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。

里氏替换原则的实现方法

里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。

根据上述理解,对里氏替换原则的定义可以总结如下:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
  • 子类中可以增加自己特有的方法
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
  • 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等

如果程序违背了里氏替换原则,则继承类的对象在父类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。

依赖倒置原则

依赖倒置原则的定义:程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。 依赖倒置原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。

依赖倒置原则,高层模块不应该依赖低层模块,都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。其核心思想是:要面向接口编程,不要面向实现编程。

举个栗子,拿顾客商店购物来说,定义顾客类如下,包含一个shopping方法:

type Customer struct {  
}
​
func (c Customer) Shopping(shop DLU)  {
    fmt.Println(shop.sell())
}

以上表示顾客在连大店进行购物,假如再加入一个新的店铺,金州店

type Customer struct {  
}
​
func (c Customer) Shopping(shop JinZhou)  {
    fmt.Println(shop.sell())
}

这显然设计不合理,违背了开闭原则。同时,顾客类的设计和店铺类绑定了,违背了依赖倒置原则。解决办法很简单,将 Shop 抽象为具体接口,shopping 入参使用接口形式,顾客类面向接口编程,如下:

type Shop interface {  
   sell() string  
}
type Customer struct {       
}  
  
func (c Customer) Shopping(shop Shop)  {  
   fmt.Println(shop.sell())  
}

类图:

依赖倒置原则总结: ● 高层模块不应该依赖低层模块,都应该依赖抽象(接口或抽象类) ● 接口或抽象类不应该依赖于实现类 ● 实现类应该依赖于接口或抽象类

场景

  • 线上学校有一系列课程
  • 用户可选择若干门课程进行学习
  • 如果把学习课程的过程直接实现为用户的方法, 则每增加一门课程, 就需要增加一个学习方法
  • 根据依赖倒置原则, 可以把学习过程抽象为学习接口, 由不同的课程实例各自实现
#GoodUser.go
#GoodUser通过实现IUser接口提供用户基本信息, 并把不同课程的学习过程, 委托给ICourse接口去实现
package dependence_inversion
​
type IUser interface {
    ID() int
    Name() string
    Study(ICourse)
}
​
type GoodUser struct {
    iID int
    sName string
}
​
func NewGoodUser(id int, name string) IUser {
    return &GoodUser{
        iID: id,
        sName: name,
    }
}
​
func (me *GoodUser) ID() int {
    return me.iID
}
​
func (me *GoodUser) Name() string {
    return me.sName
}
​
func (me *GoodUser) Study(course ICourse) {
    course.SetUser(me)
    course.Study()
}
#GolangCourse.go
#通过setter方法注入IUser, ICourse接口封装了具体课程的学习过程
package dependence_inversion
​
import "fmt"type ICourse interface {
    ID() int
    Name() string
    SetUser(IUser)
    Study()
}
​
type GolangCourse struct {
    iID int
    sName string
    xCurrentUser IUser
}
​
func NewGolangCourse() ICourse {
    return &GolangCourse{
        iID: 11,
        sName: "golang",
        xCurrentUser: nil,
    }
}
​
func (me *GolangCourse) ID() int {
    return me.iID
}
​
func (me *GolangCourse) Name() string {
    return me.sName
}
​
func (me *GolangCourse) SetUser(user IUser) {
    me.xCurrentUser = user
}
​
func (me *GolangCourse) Study() {
    fmt.Printf("%v is learning %v\n", me.xCurrentUser.Name(), me.Name())
}
​
​
// 用例
func TestDIP(t *testing.T) { 
    gu := dip.NewGoodUser(2, "Mike")
    gu.Study(dip.NewGolangCourse())
}

接口隔离原则

接口隔离原则(Interface Segregation Principle, ISP) 的定义是客户端不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上。

简单来说就是建立单一的接口, 不要建立臃肿庞大的接口。也就是接口尽量细化,同时接口中的方法尽量少,保持接口纯洁性。 我们所讲的接口主要分为两大类,

一是实例接口,比如使用new关键字产生一种实例,被new的类就是实例类的接口。从这个角度出发的话,java 中的类其实也是一种接口。二是类接口,java中常常使用interface关键字定义。

举个栗子来说,我们使用接口IPrettyGirl 来描述美女,刚开始类图可能描述如下:

但是发现该接口中包含对美女的外观描述、内在美描述等,几乎将美女的所有特性全部纳入,这显然不是一个很好的设计规范,比如在唐朝,在那个以丰腴为美的时代对美的理解就不同,就会出现单纯goodLooking过关就是美女的结果,所以这里我们需要将接口隔离拆分。将一个接口拓展为两个,增加系统灵活性及可维护性。

这里我们将美女接口拆分为内在美、外在美两个接口,系统灵活性提高了,另外接口间还能使用继承实现聚合,系统拓展性也得到了增强。

接口隔离原则总结: ● 接口尽量粒度化,保持接口纯洁性 ● 接口要高内聚,即减少对外交互

接口隔离原则(Interface Segregation Principle, ISP)指用多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口。设计接口时,应当注意以下几点: (1)一个类对另一个类的依赖应该建立在最小接口上。 (2)建立单一接口,不要建立庞大臃肿的接口。 (3)尽量细化接口,接口中的方法尽量少。

场景

  • 设计一个动物接口
  • 不同动物可能有eat(), fly(), swim()等方法
  • 设计实现动物接口的Bird类和Dog类
// IGoodAnimal.go
// 更好的接口设计. 将动物接口拆分为基本信息接口IGoodAnimal, 以及三个可选的能力接口:
// ISupportEat, ISupportFly, ISupportSwim
package main
​
type IGoodAnimal interface {
    ID() int
    Name() string
}
​
type ISupportEat interface {
    Eat() error
}
​
type ISupportFly interface {
    Fly() error
}
​
type ISupportSwim interface {
    Swim() error
}
// GoodAnimalInfo.go
// 实现IGoodAnimal接口, 提供动物的id,name等基本属性
package main
​
type GoodAnimalInfo struct {
    iID int
    sName string
}
​
func (me *GoodAnimalInfo) ID() int {
    return me.iID
}
​
func (me *GoodAnimalInfo) Name() string {
    return me.sName
}
// GoodBird.go
// 更好的Bird实现, 异味代码更少.
// 通过集成GoodAnimalInfo实现IGoodAnimal接口, 并选择性实现ISupportEat, ISupportFly.
package main
​
import "fmt"type GoodBird struct {
    GoodAnimalInfo
}
​
func NewGoodBird(id int, name string) IGoodAnimal {
    return &GoodBird{
        GoodAnimalInfo{
            id,
            name,
        },
    }
}
​
func (me *GoodBird) Eat() error {
    fmt.Printf("%v/%v is eating\n", me.Name(), me.ID())
    return nil
}
​
func (me *GoodBird) Fly() error {
    fmt.Printf("%v/%v is flying\n", me.Name(), me.ID())
    return nil
}
// GoodDog.go
// 更好的Dog实现, 异味代码更少.
// 通过集成GoodAnimalInfo实现IGoodAnimal接口, 并选择性实现ISupportEat, ISupportSwim.
package main
​
import "fmt"type GoodDog struct {
    GoodAnimalInfo
}
​
func NewGoodDog(id int, name string) IGoodAnimal {
    return &GoodDog{
        GoodAnimalInfo{
            id,
            name,
        },
    }
}
​
func (me *GoodDog) Eat() error {
    fmt.Printf("%v/%v is eating\n", me.Name(), me.ID())
    return nil
}
​
func (me *GoodDog) Swim() error {
    fmt.Printf("%v/%v is swimming\n", me.Name(), me.ID())
    return nil
}
// 单元测试
fnTestGoodAnimal := func(a isp.IGoodAnimal) {
    if it,ok := a.(isp.ISupportEat);ok {
        fnLogIfError(it.Eat)
    } else {
        t.Logf("%v/%v cannot eat", a.Name(), a.ID())
    }
​
    if it,ok := a.(isp.ISupportFly);ok {
        fnLogIfError(it.Fly)
    } else {
        t.Logf("%v/%v cannot fly", a.Name(), a.ID())
    }
​
    if it,ok := a.(isp.ISupportSwim);ok {
        fnLogIfError(it.Swim)
    } else {
        t.Logf("%v/%v cannot swim", a.Name(), a.ID())
    }
}
​
fnTestGoodAnimal(isp.NewGoodBird(11, "GoodBird"))
fnTestGoodAnimal(isp.NewGoodDog(12, "GoodDog"))

迪米特法则

迪米特法则(Law of Demeter, LOD),有时候也叫做最少知识原则(Least Knowledge Principle, LKP),它的定义是: 一个软件实体应当尽可能少地与其它实体发生相互作用。迪米特法则的初衷在于降低类之间的耦合。

举个栗子,拿教师点名来讲,体育老师需要清点班上学生人数,教师一般不是自己亲自去数,而是委托组长或班长等人去清点,即教师通过下达命令至班长要求清点人数:

// 女学生
type Girl struct {  
}
// 小组leader
type GroupLeader struct {
    girls []Girl
}
​
func (g GroupLeader) CountGirls ()  {
    fmt.Println("The sum of girls is ", len(g.girls))
}
​
// 教师
type Teacher struct {
}
// 教师委派leader去清点人数
func (t Teacher) Command(leader GroupLeader)  {
    leader.CountGirls()
}

如果去掉GroupLeader这个中间人角色,教师就会直接去清点人数,这样做会违反迪米特法则。

迪米特法则总结:

  • 类定义时尽量内敛,少用public权限修饰符,尽量使用private、protected。

迪米特法则(Law of Demeter, LoD)又叫作最少知道原则(Least KnowledgePrinciple, LKP),指一个对象应该对其他对象保持最少的了解,尽量降低类与类之间的耦合。 场景

  • TeamLeader每天需要查看未完成的项目任务数

  • TeamLeader指派TeamMember进行任务统计

  • TeamMember提供对Task的汇总方法, 返回未完成的任务数

  • 坏的设计:

    • Leader: 我需要统计未完成任务数
    • Member: 好的, 我可以统计, 但是任务清单在哪里呢?
    • Leader: ... 我稍后给你吧
  • 好的设计:

    • Leader: 我需要统计未完成任务数
    • Member: 好的. 任务清单我知道在那里, 我会搞定的
    • Leader: 好兵!
// Task.go
// 定义任务信息, 以及加载任务清单的方法
package main
​
type TaskStatus int
const OPENING TaskStatus    = 0
const DONE TaskStatus       = 1
const CANCLED TaskStatus    = 2
const DENIED TaskStatus     = 3type Task struct {
    iID int
    iStatus TaskStatus
}
​
func NewTask(id int, status TaskStatus) *Task {
    return &Task{
        id,
        status,
    }
}
​
func (me *Task) ID() int {
    return me.iID
}
​
func (me *Task) Status() TaskStatus {
    return me.iStatus
}
​
func LoadTaskList() []*Task {
    tasks := make([]*Task, 0)
    tasks = append(tasks, NewTask(1, OPENING))
    tasks = append(tasks, NewTask(2, DONE))
    tasks = append(tasks, NewTask(3, CANCLED))
    tasks = append(tasks, NewTask(4, DENIED))
    return tasks
}
// GoodTeamLerader.go
//更好的ITeamLeader实现, 只依赖了GoodTeamMember
package main
​
import "fmt"type GoodTeamLeader struct {
    iID int
    sName string
}
​
func (me *GoodTeamLeader) CountOpeningTasks() int {
    member := NewGoodTeamMember(11, "王Member")
    sum := member.countOpeningTasks()
​
    fmt.Printf("%v CountOpeningTasks, got %v", me.sName, sum)
    return sum
}
// GoodTeamMember.go
// 更好的TeamMember, 对外屏蔽了任务列表的获取细节
package main
​
type GoodTeamMember struct {
    iID int
    sName string
}
​
func NewGoodTeamMember(id int, name string) *GoodTeamMember {
    return &GoodTeamMember{
        id,
        name,
    }
}
​
func (me *GoodTeamMember) countOpeningTasks() int {
    sum := 0
    tasks := LoadTaskList()
​
    for _,it := range tasks {
        if it.Status() == OPENING {
            sum++
        }
    }
​
    return sum
}
// 单元测试
gl := lod.NewGoodTeamLeader(2, "李Leader")
gl.CountOpeningTasks()

合成复用原则

合成复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调己有对象的功能,从而达到复用。 原则是尽量首先使用合成/聚合的方式,而不是使用继承。

合成和聚合都是关联的特殊种类。合成是值的聚合 (Aggregation by Value) ,而复合是引用的聚合 (Aggregation by Reference)

我们都知道,类之有三种基本关系,分别是:关联(聚合和组合)、泛化(与继承同一概念)、依赖。

这里我们提一下关联关系,客观来讲,大千世界中的两个实体之间总是有着千丝万缕的关系,归纳到软件系统中就是两个类之间必然存在关联关系。如果一个类单向依赖另一个类,那么它们之间就是单向关联。如果彼此依赖,则为相互依赖,即双向关联。

关联关系包括两种特例:聚合和组合。聚合,用来表示整体与部分的关系或者“拥有”关系。其中,代表部分的对象可能会被代表多个整体的对象所拥有,但是并不一定 会随着整体对象的销毁而销毁,部分的生命周期可能会超越整体。好比班级和学生,班级销毁或解散后学生还是存在的,学生可以继续存在某个培训机构或步入社会,生命周期不同于班级甚至大于班级。

合成,用来表示一种强得多的“拥有”关系。其中,部分和整体的生命周期是一致的,一个合成的新的对象完全拥有对其组成部分的支配权,包括创建和泯灭。好比人的各个器官组成人一样,一旦某个器官衰竭,人也不复存在,这是一种“强”关联。

如上图所示,Heart 和Student、Teacher 都是一种“强”关联,人不能摆脱心脏而存在,即组合关系,而Student和Class、School是一种 “弱”关联,脱离了学校、班级,学生还能属于社会或其他团体,即聚合关系。

合成复用原则总结: 新对象可以调用已有对象的功能,从而达到对象复用

合成复用原则

  • 合成复用原则(Composite/Aggregate Reuse Principle, CARP)指尽量使用对象组合(has-a)或对象聚合(contanis-a)的方式实现代码复用,而不是用继承关系达到代码复用的目的。合成复用原则可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较小。
  • 继承,又被称为白箱复用,相当于把所有实现细节暴露给子类。组合/聚合又被称为黑箱复用,对类以外的对象是无法获取实现细节的

场景

  • 某订单业务系统, 需要连接数据库对产品信息进行CRUD操作

  • 不好的设计:

    • 定义DBConnection类, 实现对数据库的连接和SQL执行
    • 定义ProductDAO类, 继承DBConnection类, 并封装对产品资料的增删改查
    • 问题: ProductDAO对DBConnection的继承仅仅是为了代码复用, 不符合合成复用原则
  • 更好的设计:

    • 定义IDBConnection接口
    • 定义MysqlConnection类, 实现对mysql数据库的连接和SQL执行
    • 定义ProductDAO类, 通过Setter方法注入IDBConnection实例
// Product.go
// 产品实体类
package main
​
type Product struct {
    ID int
    Name string
    Price float64
}
​
func NewProduct(id int, name string, price float64) *Product {
    return &Product{
        id, name, price,
    }
}
// IGoodDBConnection.go
//更好的设计, 将数据库连接抽象为接口, 以支持多种数据库
package main
​
type IGoodDBConnection interface {
    Execute(sql string, args... interface{}) (error, int)
}
// GoodMysqlConnection.go
// 更好的设计, GoodMysqlConnection封装MYSQL数据库方言, 实现IGoodDBConnection接口
package main
​
import "fmt"type GoodMysqlConnection struct {
    sURL string
    sUID string
    sPWD string
}
​
func NewGoodMysqlConnection(url string, uid string, pwd string) IGoodDBConnection {
    return &GoodMysqlConnection{
        url, uid, pwd,
    }
}
​
func (me *GoodMysqlConnection) Execute(sql string, args... interface{}) (error, int) {
    fmt.Printf("GoodMysqlConnection.Execute, sql=%v, args=%v\n", sql, args)
    return nil, 0
}
// GoodProductDAO.go
//更好的设计, 通过Setter方法注入数据库方言实例(遵循了合成复用原则), 实现产品的CRUD
package main
​
type GoodProductDAO struct {
    mDBConnection IGoodDBConnection
}
​
func NewGoodProductDAO() *GoodProductDAO {
    return &GoodProductDAO{}
}
​
func (me *GoodProductDAO) SetDBConnection(it IGoodDBConnection) {
    me.mDBConnection = it
}
​
func (me *GoodProductDAO) Insert(it *Product) (error, int) {
    return me.mDBConnection.Execute("insert into product(id,name,price) values(?, ?, ?)", it.ID, it.Name, it.Price)
}
​
func (me *GoodProductDAO) Update(it *Product) (error, int) {
    return me.mDBConnection.Execute("update product set name=? price=? where id=?", it.Name, it.Price, it.ID)
}
​
func (me *GoodProductDAO) Delete(id int) (error, int) {
    return me.mDBConnection.Execute("delete from product where id=?", id)
}
// 单元测试
p := carp.NewProduct(1, "手机", 1000)
fnCallAndLog := func(fn func() (error, int)) {
    e,rows := fn()
    if e != nil {
        t.Errorf("error = %s", e.Error())
    } else {
        t.Logf("affected rows = %v", rows)
    }
}
​
con := carp.NewGoodMysqlConnection("database connection url", "sa", "123")
gd := carp.NewGoodProductDAO()
gd.SetDBConnection(con)
​
fnCallAndLog(func() (error, int) {
    return gd.Insert(p)
})
fnCallAndLog(func() (error, int) {
    return gd.Update(p)
})
fnCallAndLog(func() (error, int) {
    return gd.Delete(p.ID)
})

总结

各种原则要求的侧重点不同,总地来说:

  1. 开闭原则是核心,对拓展开放对修改关闭是软件设计、后期拓展的基石;
  2. 单一职责原则就要求我们设计接口,制定模块功能时保持模块或者接口功能单一,接口设计或功能设计尽量保持原子性,修改一处不能影响全局或其它模块;
  3. 里氏替换原则和依赖倒置原则,按照笔者的理解,这俩原则总的是要求我们要面向接口、面向抽象编程,设计程序的时候尽可能使用基类或者接口进行对象的定义或引用,而不是具体的实现,否则实现一旦有变更,上层调用者就必须做出对应变更,这样一来,整个模块可能都需要重新调整,非常不利于后期拓展。
  4. 接口隔离原则具体应用到程序中,比如我们在传统mvc开发时,service 层调用dao层一般会使用接口进行调用,各层之间尽量面向接口通信,其实也是一种降低模块耦合的方法;
  5. 迪米特法则的初衷也是为了降低模块耦合,代码示例中我们引入了类似“中间人”的概念,上层模块不直接调用下层模块,而是引入第三方进行代办,这也是为了降低模块的耦合度;
  6. 合成复用原则-节,我们介绍了聚合、组合的概念,聚合是一种弱关联,而组合是一种强关联,表现在UML类图上的话聚合是使用空心四边形加箭头表示,而组合是使用实心四边形加箭头表示, 合成复用原则总的就是要求我们尽利用好已有对象,从而达到功能复用,具体是聚合还是组合,还是一般关联, 就要看具体情况再定了。