はじめに
aptpod advent calendar トップバッターとして投稿させていただきました。
トップバッターをやったのは、私がadvent calendarに参加してみようの言い出しっぺだからです。
弊社初のadvent calendarで枠が埋まらなそうですが(来年に期待)
中にどんな人いるんだとか、そもそもaptpodっていう会社を知ってもらえるきっかけになれば幸いです。
本題
今回は、最近読んだclean architectureから単一責任の原則を
goで書いて復習したいと思います。(弊社のバックエンドでは主にgoを使ってます)
というのも、普段コード量を少なくするため、保守性をあげるために同じ処理をまとめるのは
みなさんやっているとは思うのですがどこまでまとめるかって難しくないですか?
まとめすぎると後々変更するときに実はいろんなとこに影響出てしまって
テストで落ちればいいものの、テストなかったらバグとして問題が上がってしまいますよね。
そんな迷えるプログラマのコードを書く指針として、単一責任の原則は僕が抱えていたモヤモヤを言語化してくれていました。
単一責任の原則
モジュールはたったひとつのアクターに対して責務を追うべきである
アクター
モジュールの変更を望む人たちの総称
モジュール
いくつかの関数やデータをまとめた、何らかの振る舞いをもつもの
その振る舞いは、ひとつのアクターに対する責務を追うべき
モジュールがただひとつのことを行うべきではないということに注意
ここまでではピンとこないと思うので、違反している例を追っていく
違反例 想定外の重複
給与システムを例にEmployeeクラスを見ていく
上記のEmployee クラスは単一責任の原則に違反している
なぜなら、3つのメソッドそれぞれが別々のアクター(CTO, CFO, COO)に対する責務を負っているから
コードにするとこんな風でしょうか1
package main
import "time"
type Employee struct{}
func (e *Employee) CalculatePay() int {
return 0
}
func (e *Employee) ReportHours() time.Duration {
return time.Duration(0)
}
func (e *Employee) Save(any interface{}) error {
return nil
}
//
// Actors...
//
// CTO
type CTO struct {
Employee *Employee
}
func (a *CTO) Exec(any interface{}) error {
return a.Employee.Save(any)
}
// COO
type COO struct {
Employee *Employee
}
func (a *COO) Exec() error {
_ = a.Employee.ReportHours()
// do something...
return nil
}
// CFO
type CFO struct {
Employee *Employee
}
func (a *CFO) Exec() error {
_ = a.Employee.CalculatePay()
// do something...
return nil
}
上記であればまだ問題は起こらないように見えるが、それぞれのアクターがEmployeeによって結合してしまっている
これによってどんな問題が起こりうるか見てみる
example
例えば、 CalculatePay()
と ReportHours()
の両方でロジック内に所定労働時間算出が必要だとする。
所定労働時間の算出方法は同じなので、その算出方法を regularHours()
として切り出し、
それぞれのメソッドで使うようにした。
ここまではよくあるパターンのように思われる。
type Employee struct {
WorkingType string
}
func (e *Employee) CalculatePay() int {
_ = e.regularHours()
// do something...
return 0
}
func (e *Employee) ReportHours() time.Duration {
_ = e.regularHours()
// do something...
return time.Duration(0)
}
func (e *Employee) Save(any interface{}) error {
return nil
}
func (e *Employee) regularHours() time.Duration {
switch e.WorkingType {
// 時短勤務
case "ShortTime":
return 6 * time.Hour
}
// FullTime
return 8 * time.Hour
}
ここでCFOが使う所定労働時間の算出方法ロジックに変更が必要になったとする。
しかし、COOには関係がない。(別の目的で所定労働時間を使っている)
詳細な要求として、今までフルタイムとしてきたインターン従業員の所定労働時間を6時間にしたいとする
以下のようにコードを修正したとしよう
type Employee struct {
WorkingType string
}
func (e *Employee) CalcuratePay() int {
_ = e.regularHours()
// do something...
return 0
}
func (e *Employee) ReportHours() time.Duration {
_ = e.regularHours()
// do something...
return time.Duration(0)
}
func (e *Employee) Save(any interface{}) error {
return nil
}
func (e *Employee) regularHours() time.Duration {
switch e.WorkingType {
// 時短勤務
case "ShortTime", "Intern": // "Intern" を新規追加
return 6 * time.Hour
}
// FullTime
return 8 * time.Hour
}
その仕事を行うエンジニアは、CalculatePay()からregularHours()が呼ばれていることを確認したのだが
ReportHours()から呼び出されていることには気づかなかった。
けれど、regularHours()に手を加えて要件を満たし、CFOの合意もとれ、システムがリリースされた。
この変更によって、ReportHours() を使っているCOOは問題が発覚するまで間違った値を使い続けてしまうだろう。
原因は最初に提示した通り、別々のアクターのコードを一つにまとめてしまったことにある
ではどうするか、解決策をみていく
解決策
関数を別のクラスに移動する
クラスが持つデータを関数から切り離し、アクターに応じたクラスを作成し、その中にメソッドを定義する
このようにすれば、アクターに応じたクラスは他のクラスについて知ることがなく
想定外の重複は避けられる
コードも修正する
package main
import "time"
type EmployeeData struct {
WorkingType string
}
type EmployeeSaver struct{}
func (e *EmployeeSaver) SaveEmployee(data EmployeeData) error {
return nil
}
type PayCalculator struct{}
func (e *PayCalculator) CalculatePay(data EmployeeData) int {
// do something...
return 0
}
type HourReporter struct{}
func (e *HourReporter) ReportHours(data EmployeeData) time.Duration {
// do something...
return time.Duration(0)
}
//
// Actors...
//
// CTO
type CTO struct {
Logic *EmployeeSaver
}
func (a *CTO) Exec(data EmployeeData) error {
return a.Logic.SaveEmployee(data)
}
// COO
type COO struct {
Logic *HourReporter
}
func (a *COO) Exec(data EmployeeData) error {
_ = a.Logic.ReportHours(data)
// do something...
return nil
}
// CFO
type CFO struct {
Logic *PayCalculator
}
func (a *CFO) Exec(data EmployeeData) error {
_ = a.Logic.CalculatePay(data)
// do something...
return nil
}
ただ、これでは使う側は都度クラスをインスタンス化する必要がある
今回の例のような計算ロジックはFacadeパターンを使うことで上記は解決できる
イメージとコードを下記に載せる
package main
import "time"
type EmployeeData struct {
WorkingType string
}
type EmployeeFacade struct {
saver EmployeeSaver
reporter HourReporter
calculator PayCalculator
}
func NewEmployeeFacade() *EmployeeFacade {
return &EmployeeFacade{
saver: EmployeeSaver{},
reporter: HourReporter{},
calculator: PayCalculator{},
}
}
func (e *EmployeeFacade) Save(data EmployeeData) error {
return e.saver.saveEmployee(data)
}
func (e *EmployeeFacade) CalculatePay(data EmployeeData) int {
// do something...
return e.calculator.calculatePay(data)
}
func (e *EmployeeFacade) ReportHours(data EmployeeData) time.Duration {
// do something...
return e.reporter.reportHours(data)
}
type EmployeeSaver struct{}
func (e *EmployeeSaver) saveEmployee(data EmployeeData) error {
return nil
}
type PayCalculator struct{}
func (e *PayCalculator) calculatePay(data EmployeeData) int {
// do something...
return 0
}
type HourReporter struct{}
func (e *HourReporter) reportHours(data EmployeeData) time.Duration {
// do something...
return time.Duration(0)
}
//
// Actors...
//
// CTO
type CTO struct {
Logic *EmployeeFacade
}
func (a *CTO) Exec(data EmployeeData) error {
return a.Logic.Save(data)
}
// COO
type COO struct {
Logic *EmployeeFacade
}
func (a *COO) Exec(data EmployeeData) error {
_ = a.Logic.ReportHours(data)
// do something...
return nil
}
// CFO
type CFO struct {
Logic *EmployeeFacade
}
func (a *CFO) Exec(data EmployeeData) error {
_ = a.Logic.CalculatePay(data)
// do something...
return nil
}
まとめ
違反例と解決策を見て単一責任の原則を学んだが、まだ上記のコードには問題がある
それは、EmployeeDataに変更があったときには依存するすべてのクラスに影響が及んでしまう。
これは依存関係逆転の原則を使って回避することが可能だが、それはまたの機会に。
違反例に関しては、単体テストで振る舞いを保証していれば検知できるようにも思えるので
単体テストを書いてモジュールの振る舞いを厳密に定義しとくのは大事。
プログラムの最小単位である関数やクラス設計の基本を身に着けて
内部アーキテクチャや複数サービス間の連携も考えられるようになっていきたく思う。
-
goではメソッドの先頭を大文字にすると
public
、小文字にするとprivate
になります ↩