Golang中的单一职责原则

原文地址:这里,省略了一些非重点片段。

The Single Responsibility Principle (SRP,单一职责原则)的文字定义这里我就不再次说明了,直接开始重点。

不符合单一职责的代码

如今,单一职责的范围十分广泛,比如类、函数、模块……在GO中,我们可以在结构体中使用:

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
}

上面的代码中我们定义了一个结构体EmailService,并且有一个方法Send,我们使用这个服务来发送邮件。一切看起来都还不错,但这违反了单一职责原则——EmailService不仅仅负责发送邮件,还需要把邮件内容存储到数据库中。

一旦使用了“还需”、“同时”、“并且”、“和”等词语来描述一个代码片段时,往往就违反了单一职责原则。

首先,上述代码在函数级别违反了单一职责原则,Send函数通过SMTP协议发送邮件的同时还需要将内容写到数据库。

其次,在结构体定义的级别也违反了单一职责原则,原因同上。

这么写代码造成了哪些苦果呢?

  1. 当我们想修改数据库中的表结构,却需要修改发送邮件相关的代码。
  2. 当我们想修改发送邮件等方式,却需要修改数据库相关的代码。
  3. 当我们想集成更多的发送邮件等方式,每种方式里都需要定义数据库相关的逻辑。
  4. 当我们想要对团队职责进行划分,一个团队负责发送邮件相关,一个团队负责写入数据库相关,那么这部分代码必然在2个团队中重复出现。
  5. 这个代码实际上是不可测试的
  6. ……

所以让我们来重构这段代码。

符合单一职责的代码

在这种情况下划分职责,使代码块只有一个存在的理由,我们应该为每个代码块定义一个结构体。

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)
}

这里我们提供了2个新的结构体,EmailDBRepository实现了EmailRepository接口,负责和数据打交道。EmailSMTPSender实现了EmailSender,负责使用SMTP协议来发送邮件。

最后使用EmailService来整合上面两者。

这里你可能会质疑:EmailService不依然是承担了2种职责吗?这里我们貌似只是做了抽象而已?

并非如此。EmailService并没有承担操作数据库和发送邮件的职责,它的职责是将处理电子邮件的请求委托给底层服务。

承担责任和授权责任是有区别的。如果对某一特定代码的调整可以移除整个功能,我们就会讨论承担(holding)。如果在删除特定代码后,这种功能仍然存在,那么我们就是在说委托(delegation)。

如果我们删除EmailService,操作数据库和发送邮件的功能仍然存在,这也就是说它并没有承担2种功能。

更多例子

正如我们上面说的,单一职责原则并不单单用于结构体中,而是可以用于各种代码中。看看下面的例子:

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
}

现在我们有了2个新函数extractRawToken负责从header里提取jwt原始数据,如果header中的key变了,我们只需要改这个函数就可以了。extractClaims负责从原始jwt数据中提取claims, 原来的extractUsername函数负责从前两者从返回的claims中获取指定的值。

此外日常中还有更多的例子,有时候我们这么使用因为某些框架提供了错误的指导或者我们懒得提供更合适的实现:

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模式的实现,在这个例子中,我们不仅需要将数据存储到数据库,还添加了一些业务逻辑到User结构体中。

这里我们混合了Active Record和DDD中的实体模式,为了写出优雅的代码,我们需要对struc进行分割:一个用于存储数据到数据库,另一个则扮演实体角色。相同的错误也在下面的代码中出现:

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
}

这里的Wallet结构体也承担了2种职责,第二个职责是通过golang的tag语法由gorm包隐式提供的数据库表映射。如果我们想修改数据表结构,我们需要修改这个结构体;如果我们想修改业务逻辑,还是需要修改这个结构体:

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"`
}

上面的代码片段同样违反了单一职责原则,在我看来甚至是最糟糕的一类!我们都没法切分出更小的结构体去承担职责。Transaction这个结构体,不仅需要维持到数据库表的映射,还承担了返回JSON响应给RESTAPI的职责,此外还有验证请求的JSON数据的功能,一个结构体承担了上面所有的职责。

所有这些例子迟早都需要修改。如果我们把它们保存在我们的代码中,它们就是无声的问题,很快就会开始打破我们的逻辑。

总结

单一职责原则是SOLID中的第一个S,它要求一段代码只为一个目的存在。一个结构体可以负责一个职责或者委派给其他结构体,无论何时一段代码承担了多个职责,我们都应该重构它。