什么是SOLID
?
以下引用wikipedia上的解释:
SOLID
指面向对象编程和面向对象设计的五个基本原则。当这些原则被一起应用时,它们使得一个程序员开发一个容易进行软件维护和扩展的系统变得更加可能。SOLID
所包含的原则是通过引发编程者进行软件源代码的代码重构进行软件的代码异味清扫,从而使得软件清晰可读以及可扩展时可以应用的指南。SOLID
被典型的应用在测试驱动开发上,并且是敏捷开发以及自适应软件开发的基本原则的重要组成部分
首字母 | 指代 | 概念 |
---|---|---|
S | 单一功能原则 | 认为“对象应该仅具有一种单一功能”的概念 |
O | 开闭原则 | 认为“软件应该是对于扩展开放的,但是对于修改封闭的”的概念。 |
L | 里氏替换原则 | 认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念。 |
I | 接口隔离原则 | 认为“多个特定客户端接口要好于一个宽泛用途的接口” 的概念。 |
D | 依赖反转原则 | 认为一个方法应该遵从“依赖于抽象而不是一个实例”的概念,依赖注入是该原则的一种实现方式。 |
这些原则是Bob大叔第一次在文章中提出。后来在他的书《Clean Architecture》中塑造了这些原则。
在这篇文章中,我打算通过围棋的例子来开始我对所有SOLID原则的实践列表中的第一个SOLID原则-单一责任原则,也就是SOLID这个词中的字母S所代表的原则。
单一职责原则实践
不符合单一职责原则时
单一责任原则(SRP,以下都简称SRP)指出,每个软件模块应该有且仅有一个存在的理由。
上面这句话是Bob大叔自己写的。它的意思是最初绑定在一个模块上,通过映射到组织的日常工作来划分职责。
如今,SRP的范围很广,它触及软件的不同方面。我们可以在类、函数、模块中使用。当然,在Go中我们可以在struct中使用。
type EmailService struct {
db *gorm.DB
smtpHost string
smtpPassword string
smtpPort int
}
func NewEmailService(db *gorm.DB, smtpHost string, smtpPassword string, smtpPort int) *EmailService {
return &EmailService{
db: db,
smtpHost: smtpHost,
smtpPassword: smtpPassword,
smtpPort: smtpPort,
}
}
func (s *EmailService) Send(from string, to string, subject string, message string) error {
email := EmailGorm{
From: from,
To: to,
Subject: subject,
Message: message,
}
err := s.db.Create(&email).Error
if err != nil {
log.Println(err)
return err
}
auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost)
server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort)
err = smtp.SendMail(server, auth, from, []string{to}, []byte(message))
if err != nil {
log.Println(err)
return err
}
return nil
}
在上面的代码块。我们有一个struct类型的EmailService,其中包含一个方法Send。我们使用这个服务来发送邮件。即使运行起来没有问题,但我们意识到,当我们从表面上看时,这段代码破坏了SRP。
EmailService的职责不仅仅是发送邮件,而是将邮件存储到DB和
通过SMTP协议发送。
仔细看一下上面的句子。“和"字是粗体的,是有目的的。使用这样的表达方式看起来并不像我们描述单一责任的情况。
只要描述某些代码结构的职责时需要使用 “和 “字,就已经破坏了单一职责原则。
在上面的例子中,在代码层面我们在很多方面破坏了SRP。
- 在函数层面上。函数Send既负责在数据库中存储消息,又负责通过SMTP协议发送邮件。
- 结构体EmailService,它也有两个职责,在数据库内存储和发送邮件。
这样的代码有什么后果呢?
- 当我们改变表结构或存储类型时,我们需要改变通过SMTP发送邮件的代码。
- 当我们想集成Mailgun或Mailjet等发送邮件的方式时,我们需要改变在MySQL数据库中存储数据的代码。
- 如果我们在应用程序中选择不同的发送邮件的方式,每个方式都需要有一个逻辑来存储数据库中的数据。
假设我们决定将应用程序的责任分成两个,一个负责维护数据库,另一个负责整合电子邮件供应商。在这种情况下,他们将在同一个代码断上工作。
这种代码实现实际上是无法用单元测试来测试的。
所以,让我们重构这段代码。
如何做到符合单职责原则
在这种情况下,为了分清职责,使代码块只有一个存在的理由,我们应该为它们分别定义一个结构体å。
这实际上意味着有一个单独的结构体用于将数据存储在某些存储器中,另一个不同的结构体用于通过与电子邮件提供商的集成来发送电子邮件。如下:
type EmailGorm struct {
gorm.Model
From string
To string
Subject string
Message string
}
type EmailRepository interface {
Save(from string, to string, subject string, message string) error
}
type EmailDBRepository struct {
db *gorm.DB
}
func NewEmailRepository(db *gorm.DB) EmailRepository {
return &EmailDBRepository{
db: db,
}
}
func (r *EmailDBRepository) Save(from string, to string, subject string, message string) error {
email := EmailGorm{
From: from,
To: to,
Subject: subject,
Message: message,
}
err := r.db.Create(&email).Error
if err != nil {
log.Println(err)
return err
}
return nil
}
type EmailSender interface {
Send(from string, to string, subject string, message string) error
}
type EmailSMTPSender struct {
smtpHost string
smtpPassword string
smtpPort int
}
func NewEmailSender(smtpHost string, smtpPassword string, smtpPort int) EmailSender {
return &EmailSMTPSender{
smtpHost: smtpHost,
smtpPassword: smtpPassword,
smtpPort: smtpPort,
}
}
func (s *EmailSMTPSender) Send(from string, to string, subject string, message string) error {
auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost)
server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort)
err := smtp.SendMail(server, auth, from, []string{to}, []byte(message))
if err != nil {
log.Println(err)
return err
}
return nil
}
type EmailService struct {
repository EmailRepository
sender EmailSender
}
func NewEmailService(repository EmailRepository, sender EmailSender) *EmailService {
return &EmailService{
repository: repository,
sender: sender,
}
}
func (s *EmailService) Send(from string, to string, subject string, message string) error {
err := s.repository.Save(from, to, subject, message)
if err != nil {
return err
}
return s.sender.Send(from, to, subject, message)
}
这里我们提供了两个新的结构体。一个是EmailDBRepository,作为EmailRepository接口的一个实现。它包括对底层数据库中数据持久化的实现。
第二个结构体是EmailSMTPSender,实现了EmailSender接口。这个结构体只负责通过SMPT协议发送邮件。
最后,新的EmailService包含上面的两个接口,委托发送邮件和持久化数据。 可能会出现一个问题:EmailService仍然有多种职责,因为它仍然持有存储和发送邮件的职责?是不是看起来我们只是做了一个抽象,但职责仍然存在?
在这里,情况并非如此。EmailService并不承担存储和发送邮件的职责。它将其委托给下面的结构。它的职责是将处理邮件的请求委托给底层服务。
持有和委托之间是有区别的。如果对特定代码的改写可以消除职责的全部目的,我们就会谈论持有。如果在删除特定代码后,该职责仍然存在,那么我们就谈及委托。
如果我们删除了完整的EmailService,仍然负责在数据库中存储数据并通过SMTP发送邮件。这意味着我们可以肯定地说,EmailService并没有持有这两项职责。
其他实践
正如我们之前可以看到的,SRP适用于多种不同的实现方式,而不仅仅是结构体。可以看到,我们可以打破它对函数的影响,但这个例子已经在结构内打破了SRP的影子。
为了更好地理解SRP原则在函数中的应用,让我们看看下面这个例子。
import "github.com/dgrijalva/jwt-go"
func extractUsername(header http.Header) string {
raw := header.Get("Authorization")
parser := &jwt.Parser{}
token, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil {
return ""
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return ""
}
return claims["username"].(string)
}
函数extractUsername很短。它提供了对从HTTP头中提取原始JWT令牌的支持和
如果里面包含用户名,则返回。
再一次,你可能注意到粗体字 和
。这个方法有多重职责。如何描述并不重要。我们不能避免使用"和"字来描述该函数的作用。
与其重新描述函数的职责,我们不如重构函数本身。如下:
func extractUsername(header http.Header) string {
raw := extractRawToken(header)
claims := extractClaims(raw)
if claims == nil {
return ""
}
return claims["username"].(string)
}
func extractRawToken(header http.Header) string {
return header.Get("Authorization")
}
func extractClaims(raw string) jwt.MapClaims {
parser := &jwt.Parser{}
token, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
if err != nil {
return nil
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil
}
return claims
}
如上我们新增了两个新的函数。第一个extractRawToken,职责是从HTTP头中提取原始JWT令牌。如果我们改变了头中持有令牌的一个键,我们应该只碰一个方法。
第二个是extractClaims。这个方法负责从一个原始的JWT令牌中提取Claims。最后,我们的旧函数extractUsername在将提取令牌的请求委托给底层方法后,从claim中获取特定的值。
还有更多的例子。其中许多是我们日常经常使用的。一些原因是一些框架使用了错误的方法,还有就是我们懒得提供正确的实现。
type User struct {
db *gorm.DB
Username string
Firstname string
Lastname string
Birthday time.Time
//
// some more fields
//
}
func (u User) IsAdult() bool {
return u.Birthday.AddDate(18, 0, 0).Before(time.Now())
}
func (u *User) Save() error {
return u.db.Exec("INSERT INTO users ...", u.Username, u.Firstname, u.Lastname, u.Birthday).Error
}
上面的例子展示了Active Record这一模式的典型实现。在这种情况下,我们还在用户结构中添加了业务逻辑,而不仅仅是将数据存储到数据库中。
在这里,我们混合了Active Record和领域驱动设计中的Entity模式。为了正确传递代码,我们需要提供单独的结构体:一个用于在数据库中持久化数据,另一个用于发挥实体的作用。下面的例子中也有同样的问题。
type Wallet struct {
gorm.Model
Amount int `gorm:"column:amount"`
CurrencyID int `gorm:"column:currency_id"`
}
func (w *Wallet) Withdraw(amount int) error {
if amount > w.Amount {
return errors.New("there is no enough money in wallet")
}
w.Amount -= amount
return nil
}
以上又有两个职责,第二个责任(通过Gorm包对数据库中的表进行映射)没有直接表达为一种算法,而是表达为Go标签。
即使现在,钱包结构也打破了SRP原则,因为它扮演着多种角色。如果我们改变数据库方案,我们需要调整这个结构体。如果我们改变取款的业务规则,我们也需要调整这个结构体。
type Transaction struct {
gorm.Model
Amount int `gorm:"column:amount" json:"amount" validate:"required"`
CurrencyID int `gorm:"column:currency_id" json:"currency_id" validate:"required"`
Time time.Time `gorm:"column:time" json:"time" validate:"required"`
}
上面的代码片段是另一个破坏SRP的例子。而且,在我看来,这是最悲惨的一个例子,我们无法提供一个更小的结构来承担更多的责任。
通过查看Transaction结构,我们发现它是用来描述与数据库中的表的映射关系,并成为REST API中JSON响应的持有者,由于有验证部分,它也可以成为API请求的JSON主体。一个结构体可以完成一切。
所有这些例子迟早都需要进行调整。只要我们在代码中保留它们,它们就是隐藏的问题,很快就会开始破坏我们的代码。
总结
单一责任原则是SOLID原则中的第一个原则。它代表了SOLID中的一个字母S。它表示一个代码结构必须只有一个存在的理由。我们把这些理由看作是职责。一个结构体可以持有职责或委托职责。每当一个结构体持有多个职责时,我们应该重构那段代码。
参考链接:
1、https://levelup.gitconnected.com/practical-solid-in-golang-single-responsibility-principle-20afb8643483
2、https://zh.wikipedia.org/wiki/SOLID_(%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E8%AE%BE%E8%AE%A1)