赞
踩
七大设计原则:单一职责原则、开闭原则、依赖倒置原则、接口隔离原则、迪米特原则、里氏替换原则、合成复用原则
单一职责原则(SRP):一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
假设我们要做一个在手机上玩的俄罗斯方块游戏,Game类可以设计如下:
type Game struct {
x int64
y int64
}
func (game *Game) Show() {
fmt.Println(game.x, game.y)
}
func (game *Game) Move() {
game.x--
game.y++
}
游戏的显示和移动都放在类Game里。后面需求变更了,不但要在手机上显示,还需要再电脑上显示,而且还有两人对战模式,这些更改主要和显示有关。
这时最好将Show和Move拆分到两个函数,这样不但可以复用Move的逻辑,而且今后无论如何更改Show,都不会影响Move所在的类。
但因为一开始Game职责不单一,整个系统中很多位置使用同一个Game变量调用Show和Move,对这些位置的改动和测试是十分浪费时间的。
对扩展开放、修改关闭(OCP):添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
假设我们要做一个API接口监控告警,如果TPS或Error超过指定值,则根据不同的紧急情况通过不同方式(邮箱、电话)通知相关人员。
业务实现流程为:
所以,我们可以设置三个类,AlertRules存放报警规则,Notification用来通知,Alert用来比较。
//存储报警规则 type AlertRules struct { } func (alertRules *AlertRules) GetMaxTPS(api string) int64 { if api == "test" { return 10 } return 100 } func (alertRules *AlertRules) GetMaxError(api string) int64 { if api == "test" { return 10 } return 100 } const ( SERVRE = "SERVRE" URGENT = "URGENT" ) //通知类 type Notification struct { } func (notification *Notification) Notify(notifyLevel string) bool { if notifyLevel == SERVRE { fmt.Println("打电话") } else if notifyLevel == URGENT { fmt.Println("发短信") } else { fmt.Println("发邮件") } return true } //检查类 type Alert struct { alertRules *AlertRules notification *Notification } func CreateAlert(a *AlertRules, n *Notification) *Alert { return &Alert{ alertRules: a, notification: n, } } func (alert *Alert) Check(api string, tps int64, errCount int64) bool { if tps > alert.alertRules.GetMaxTPS(api) { alert.notification.Notify(URGENT) } if errCount > alert.alertRules.GetMaxError(api) { alert.notification.Notify(SERVRE) } return true } func main() { alert := CreateAlert(new(AlertRules), new(Notification)) alert.Check("test", 20, 20) }
虽然程序比较简陋,但是是面向对象的,而且能跑。
对于这个需求,有很多可能的变动点,最可能变的是增加新的报警指标。现在新需求来了,如果每秒内接口超时量超过指定值,也需要报警,我们需要怎么做?
如果在原有代码上修改,我们需要
AlertRules上添加新的规则
Check函数增加新的入参timeoutCount
Check函数中增加新的判断逻辑
if timeoutCount > alert.alertRules.GetMaxTimeoutCount(api) {
alert.notification.Notify(SERVRE)
}
这会导致一些问题,一是Check可能在多个地方被引用,所以这些位置都需要进行修改,二是更改了Check逻辑,需要重新做这部分的测试。如果说我们做第一版没有预料到这些变化,但现在我们找到了可能的变更点,我们是否有好的方案能够做好扩展,让下次改动量最小?
我们把Alert中Check做的事情拆散,放到对应的类里,这些类都实现了AlertHandler接口。
//优化 type ApiStatInfo struct { api string tps int64 errCount int64 timeoutCount int64 } type AlertHandler interface { Check(apiStatInfo ApiStatInfo) bool } type TPSAlertHandler struct { alertRules *AlertRules notification *Notification } func CreateTPSAlertHandler(a *AlertRules, n *Notification) *TPSAlertHandler { return &TPSAlertHandler{ alertRules: a, notification: n, } } func (tPSAlertHandler *TPSAlertHandler) Check(apiStatInfo ApiStatInfo) bool { if apiStatInfo.tps > tPSAlertHandler.alertRules.GetMaxTPS(apiStatInfo.api) { tPSAlertHandler.notification.Notify(URGENT) } return true } type ErrAlertHandler struct { alertRules *AlertRules notification *Notification } func CreateErrAlertHandler(a *AlertRules, n *Notification) *ErrAlertHandler { return &ErrAlertHandler{ alertRules: a, notification: n, } } func (errAlertHandler *ErrAlertHandler) Check(apiStatInfo ApiStatInfo) bool { if apiStatInfo.errCount > errAlertHandler.alertRules.GetMaxError(apiStatInfo.api) { errAlertHandler.notification.Notify(SERVRE) } return true } type TimeOutAlertHandler struct { alertRules *AlertRules notification *Notification } func CreateTimeOutAlertHandler(a *AlertRules, n *Notification) *TimeOutAlertHandler { return &TimeOutAlertHandler{ alertRules: a, notification: n, } } func (timeOutAlertHandler *TimeOutAlertHandler) Check(apiStatInfo ApiStatInfo) bool { if apiStatInfo.timeoutCount > timeOutAlertHandler.alertRules.GetMaxTimeOut(apiStatInfo.api) { timeOutAlertHandler.notification.Notify(SERVRE) } return true }
Alert类增加成员变量handlers []AlertHandler,并添加如下函数
//版本2
func (alert *Alert) AddHanler(alertHandler AlertHandler) {
alert.handlers = append(alert.handlers, alertHandler)
}
func (alert *Alert) CheckNew(apiStatInfo ApiStatInfo) bool {
for _, h := range alert.handlers {
h.Check(apiStatInfo)
}
return true
}
调用方式如下:
func main() { alert := CreateAlert(new(AlertRules), new(Notification)) alert.Check("test", 20, 20) //版本2,alert其实已经不需要有成员变量AlertRules和Notification了 a := new(AlertRules) n := new(Notification) alert.AddHanler(CreateTPSAlertHandler(a, n)) alert.AddHanler(CreateErrAlertHandler(a, n)) alert.AddHanler(CreateTimeOutAlertHandler(a, n)) apiStatInfo := ApiStatInfo{ api: "test", timeoutCount: 20, errCount: 20, tps: 20, } alert.CheckNew(apiStatInfo) }
这样今后无论增加多少报警指标,只需要创建新的Handler类,放入到alert中即可。代码改动量极小,而且不需要重复测试。
https://github.com/shidawuhen/asap/blob/master/controller/design/3principle.go
里氏替换原则(LSP):子类对象能够替换程序(program)中父类对象出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
多态与里氏替换原则的区别:多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
里式替换原则不仅仅是说子类可以替换父类,它有更深层的含义。
子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。所以我们可以通过几个点判断是否违反里氏替换原则:
里氏替换原则可以提高代码可扩展性。假设我们需要做一个发送信息的功能,最初只需要发送站内信。
type Message struct {
}
func (message *Message) Send() {
fmt.Println("message send")
}
func LetDo(notify *Message) {
notify.Send()
}
func main() {
LetDo(new(Message))
}
实现完成后,许多地方都调用LetDo发送信息。后面想用SMS替换站内信,处理起来就很麻烦了。所以最好的方案是使用里氏替换原则,丝毫不影响新的通知方法接入。
//里氏替换原则 type Notify interface { Send() } type Message struct { } func (message *Message) Send() { fmt.Println("message send") } type SMS struct { } func (sms *SMS) Send() { fmt.Println("sms send") } func LetDo(notify Notify) { notify.Send() } func main() { //里氏替换原则 LetDo(new(Message)) }
接口隔离原则(ISP):客户端不应该强迫依赖它不需要的接口
接口隔离原则与单一职责原则的区别:单一职责原则针对的是模块、类、接口的设计。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。如果把“接口”理解为单个API接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。如果把“接口”理解为OOP中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
假设项目用到三个外部系统:Redis、MySQL、Kafka。其中Redis和Kafaka支持配置热更新。MySQL和Redis有显示监控功能。对于这个需求,我们需要怎么设计接口?
一种方式是将所有功能放到一个接口中,另一种方式是将这两个功能放到不同的接口中。下面的代码按照接口隔离原则编写:
//接口隔离原则 type Updater interface { Update() bool } type Shower interface { Show() string } type RedisConfig struct { } func (redisConfig *RedisConfig) Connect() { fmt.Println("I am Redis") } func (redisConfig *RedisConfig) Update() bool { fmt.Println("Redis Update") return true } func (redisConfig *RedisConfig) Show() string { fmt.Println("Redis Show") return "Redis Show" } type MySQLConfig struct { } func (mySQLConfig *MySQLConfig) Connect() { fmt.Println("I am MySQL") } func (mySQLConfig *MySQLConfig) Show() string { fmt.Println("MySQL Show") return "MySQL Show" } type KafkaConfig struct { } func (kafkaConfig *KafkaConfig) Connect() { fmt.Println("I am Kafka") } func (kafkaConfig *KafkaConfig) Update() bool { fmt.Println("Kafka Update") return true } func ScheduleUpdater(updater Updater) bool { return updater.Update() } func ServerShow(shower Shower) string { return shower.Show() } func main() { //接口隔离原则 fmt.Println("接口隔离原则") ScheduleUpdater(new(RedisConfig)) ScheduleUpdater(new(KafkaConfig)) ServerShow(new(RedisConfig)) ServerShow(new(MySQLConfig)) }
这种方案比起将Update和Show放在一个interface中有如下好处:
依赖倒转原则(DIP):高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。核心思想是:要面向接口编程,不要面向实现编程。
这个可以直接用里式替换中的例子来讲解。LetDo就使用了依赖倒转原则,提高了代码的扩展性,可以灵活地替换依赖的类。
迪米特法则(LOD):不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口
迪米特法则主要用来实现高内聚低耦合。
高内聚:就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中
松耦合:在代码中,类与类之间的依赖关系简单清晰
减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
假设我们要做一个搜索引擎爬取网页的功能,功能点为
所以我们设置三个类NetworkTransporter负责底层网络、用于获取数据,HtmlDownloader下载网页,Document用于分析网页。下面是符合迪米特法则的代码
//迪米特法则 type Transporter interface { Send(address string, data string) bool } type NetworkTransporter struct { } func (networkTransporter *NetworkTransporter) Send(address string, data string) bool { fmt.Println("NetworkTransporter Send") return true } type HtmlDownloader struct { transPorter Transporter } func CreateHtmlDownloader(t Transporter) *HtmlDownloader { return &HtmlDownloader{transPorter: t} } func (htmlDownloader *HtmlDownloader) DownloadHtml() string { htmlDownloader.transPorter.Send("123", "test") return "htmDownloader" } type Document struct { html string } func (document *Document) SetHtml(html string) { document.html = html } func (document *Document) Analyse() { fmt.Println("document analyse " + document.html) } func main() { //迪米特法则 fmt.Println("迪米特法则") htmlDownloader := CreateHtmlDownloader(new(NetworkTransporter)) html := htmlDownloader.DownloadHtml() doc := new(Document) doc.SetHtml(html) doc.Analyse() }
这种写法可以对应迪米特法则的两部分
尽量使用对象组合、聚合,而不是继承关系达到软件复用的目的
不用多说了,A继承B,那么A就是B,但反过来B不一定是A。所以继承是is-a的关系,是一种纵向的关系。
关联是两个类之间有联系,这种联系是很微弱的或者说是临时的,比如人过河要坐船,人和船就产生了联系,但是这种联系是临时的,人过了河就跟船没关系了。表现在代码层面就是A关联B,那么B以A的成员变量的形式存在在类A中。
聚合是关联关系的一种,也是两个事物要产生关系,但是这个关系的强度要大于关联关系,不是微弱或者临时的,是比较长久的关联,比如员工和公司的关系。但是这种互相关联的两个对象彼此又是独立的,各有各的生命周期,如果拆散这种关系那么各自也能玩儿的转。就好比员工和公司是一种长期的关系,但是公司和员工是彼此独立的,各有各的生命周期,公司没有张三这个员工也能玩儿的转,反过来张三不在这个公司去别的公司也玩儿的转。这种关系体现的是has-a的关系,代码表现和关联关系一样。
组合是比聚合更紧密的关联关系,是一种强关联关系,具有组合关系的两个事物谁都不能缺少谁,如果断开这种关系各自都玩儿不转了,比如人和大脑的关系。这中关系是contains-a的关系,代码表现和关联关系一样。
所以从代码层面上看关联、聚合和组合表现形式都是一样的,得从语义上来区分,他们三个都是横向的关系。
那什么时候用继承又什么时候用组合呢?如果你要对现实世界建模,比如要描述狗类和动物的时候就要用继承,因为这些在概念上具有层次关系;如果你想表述你个实体要借助另一个实体来完成某项任务或者想表达has-a或者contains-a的时候就要用组合;如果只是在实现层面想达到代码复用的目的,那么优先使用组合。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。