デザインパターン~Visitor: データ構造とデータのスキャン操作を完全分離~
遂に、実体から振る舞いをオブジェクトとして独立させるタイプとして、最後のデザインパターンの紹介です。
開発で扱うデータ構造が、複雑な階層構造になっていることが多々あります。
更に、そのような巨大なデータ構造を持ったシステムにおいて、各データに対する様々な内部処理が必要とされることも、往々にしてあります。
例えば、特定期間におけるCPUの稼働率に対するコスト計算やリクエストに対するバリデーションなどが挙げられます。
これらのような内部処理は、日々様々な用途に応じて変更・追加がされることが多いです(例えば、「CPUの稼働率あたりのコストを変えたい」など)
データ構造と内部処理(スキャン)を完全に分離する
ここで、データ構造自体の変更頻度を考えてみましょう。
データ構造の構成要素に対する内部処理の詳細に比較したら、断然安定度が変わってくることがわかると思います。
そこで、「データ構造の構成要素」と「各要素に対する処理」を分離させます。
visitor対応窓口を1つだけ用意すればいい
データ構造は、「データ構造の各要素に対する処理」の詳細を知りたくありません。
例えば、内部処理を要素クラスに直接メソッドとして追加していったら、あっという間にそのデータ構造の要素クラスのコードは膨れ上がります。
更に、安定度の低い内部処理の仕様の変更のたびに、データ構造の要素クラスのコードに手を加えることは、データ構造の要素クラス自体への影響の波及リスクが生じます。
このことから、内部処理の対象であるデータ構造の各要素が、特定の処理をする人(visitor)を受け入れる窓口を1つ設けて、visitorたちに勝手にデータ構造の要素の内部をスキャンして処理してもらうようにするのが良さそうです。
これによって、データ構造の要素に対する処理を、いつどのように変えたとしても、データ構造の要素に対する影響を考慮する必要がなくなります。
内部のスキャン処理を隠蔽する
クライアントコード上で、利用者はメソッドを呼び出すだけで、データ構造の要素のスキャン処理の結果を得られるようにしたいです。
さもなければ、利用者は毎回データ構造の各要素の詳細を把握し、ループによるスキャン処理を実装する必要が生じてきます。
これによって、あっという間にクライアントコード上はforループで散らかってしまいます。
なので、クライアントコード上で、データ構造の各要素の詳細を理解せずとも、特定の内部処理の結果を得るように実装をする必要があります。
インフラリソースの管理を実装
ここまでの内容を踏まえて、実際にKubernetesのような、 「階層構造を持つインフラリソース」 をスキャンして、「コスト計算」と「バリデーション(検査)」の結果を取得する実装をしていきます。
まず、上記のvisitorパターンに沿いながら、アウトラインを紹介します。
今回のインフラのリソースに関しても同様に、Visitorパターンの構成要素である「データ構造」と「スキャン操作」として分離するように実装します。
1. データ構造:滅多に変わることがない構造→Pod,Service
インフラ(Infra)という大きなデータ構造は、PodやServiceのような様々なResource(要素)を持っています。
その上で、各リソースの実体では、visitorを受け入れる窓口的なメソッドAcceptを実装します。
今回、ResourcerというインターフェースのメソッドとしてAcceptを定義することで、Infraは全てのリソースをリストとして管理するようにします。
2. スキャン操作:高い頻度で変更される→costCalculator, validator
インフラの持つ様々なリソースに対する所望のスキャン処理群(visitors)です。
この時、全てのスキャン処理は、全てのリソースに対して実装をする必要があります。
例えば、「コスト計算」であれば、「Podに対するコスト計算」「Serviceに対するコスト計算」というように実装します。
インフラの定義
インフラの構成要素であるリソース(Resource)をインターフェースResourcerの多態性を利用してリストとして管理
package infra
type Resourcer interface{
Accept(visitor resources.Visitor)
}
type Infra struct{
Resources []Resourcer
}
// 新しいスキャン操作(visitor)を追加するたびに
// インフラの構成要素全てがこの新たなvisitorを受け入れる
func (i *Infra) Accept(visitor resources.Visitor){
for _, resource := range i.Resources{
resource.Accept(visitor)
}
}
func (i *Infra) Add(resource Resource){
i.Resources = append(i.Resources, resource)
}
インフラの構成要素であるリソースおよびリソースのスキャン操作インターフェースの定義
🌟各リソース実体がvisitorインターフェースで実装されたメソッドの引数に自身のパラメータを渡している
package resources
// visitorが各リソースにアクセスするためのインターフェース
type Visitor interface{
VisitPod(pod *Pod)
VisitService(service *Service)
}
// リソースの実体
package resources
type Pod struct{
Name string
CPURequest int
}
func (p *Pod) Accept(visitor Visitor){
visitor.VisitPod(p)
}
type Service struct{
Name string
Type string
}
func (s *Service) Accept(visitor Visitor){
visitor.VisitService(s)
}
具体的なスキャン操作(visitor)の実装
今回は「コスト計算」と「バリデーション」の2つ
🌟Visitorインターフェースのメソッドは、あくまで「visitorがデータ構造要素にアクセスする」ことがメイン
visitorによるスキャン操作の詳細は、別のメソッドで実装し、アクセスメソッド上で実行するように書くと「リソースアクセス」と「スキャン操作」の責務をきれいに分離できる!
🚨visitors具象クラス群とvisitorインターフェースは絶対に同じパッケージにまとめてはいけない
例えばcpuの稼働率に応じたコストの計算方法も、ビジネスの状況に応じて変化する恐れがザラにある。つまり、これらは安定度が異なる
package visitors
type CostCalculator struct{
TotalCost int
}
func (c *CostCalculator) VisitPod(pod *resources.Pod){
// ここで実際にリソースpodを用いてコスト計算処理を実装していく
c.calculateBasedOnCPURequest(pod.CPURequest)
}
func (c *CostCalculator) VisitService(service *resources.Service){
c.calculateBasedOnServiceType(service.Type)
}
func (c *CostCalculator) calculateBasedOnCPURequest(cpuRequest int) {
c.TotalCost += cpuRequest * 100
}
func (c *CostCalculator) calculateBasedOnServiceType(serviceType string){
if serviceType == "LoadBalancer"{
c.TotalCost += 1000
}else{
c.TotalCost += 10
}
}
type Validator struct{
Errors []string
}
func (v *Validator) VisitPod(pod *resources.Pod){
validateCPURequest(pod.CPURequest)
}
func (v *Validator) VisitService(service *resources.Service){
validateName(service.Name)
}
func (v *Validator) validateCPURequest(cpuRequest int, podName string){
if cpuRequest == 0{
val.Errors = append(val.Errors, fmt.Sprintf("Pod '%s': CPUリクエストが設定されていません", podName))
}
}
func (v *Validator) validateName(name string){
if serviceName == "" {
val.Errors = append(val.Errors, "サービスの名前が空です")
}
}
クライアントコード
利用者が基本的にやることはたったの3ステップ
- インフラインスタンスを生成
- インフラの構成要素であるリソースインスタンスの生成&インフラへの追加
- リソースに対する所望のスキャン操作を、visitorインスタンスを生成する形で実行
🌟例えリソースの数が増えたとしても、上記の3ステップは変わらない。
🌟リソースに対するスキャン操作をするためのループ処理などが完全に隠蔽されていることに注目!
func main() {
infra := &infra.Infra{}
// Pod, Serviceインスタンスをリソースとして追加
infra.Add(&resources.Pod{Name: "web-app", CPURequest: 100})
infra.Add(&resources.Service{Name: "public-lb", Type: "LoadBalancer"})
// エラーになるデータ
infra.Add(&resources.Pod{Name: "forgot-cpu", CPURequest: 0})
infra.Add(&resources.Service{Name: "", Type: "ClusterIP"})
//コスト計算→今回は単に取得結果を出力する
costCalculator := &visitors.CostCalculator{}
// インフラの窓口に通す→これだけでコスト計算処理は全てのリソースで完了!
fmt.Printf("%sのCPU稼働率に対するコストは%dです\n", pod1.Name, costCalculator.TotalCost)
//エラーチェック
validator := &visitors.Validator{}
infra.Accept(validator)
if len(validator.Errors) > 0{
fmt.Println(">> 設定ミスが見つかりました:")
for _, err := range validator.Errors{
fmt.Printf("- %v, err)
}
}
}
VisitorとIteratorの違い
ここで、「データの集まり」に関する2つのデザインパターンの違いを明確にしていきましょう。
Iterator: データを走査する手段を抽象化する
Iteratorの主な目的は、リストやツリー・グラフといったコレクションの内部実装を隠蔽し、要素にアクセスするためのインターフェースを提供することです。
つまり、要素に対して、「どのような順番で、どのように走査するか?」が念頭にあります。
一方で、取り出した要素の型に応じて処理を使い分けようとしたら、それぞれに対応するための条件式を書く必要性が生まれます。
このことから、Iteratorは、データの集まりから単にデータを走査したい場合に有効です。
尚且つ、データの型が単一か、もしくは共通のインターフェースで十分処理できることが望ましいです。
Visitor: データに対する処理(振る舞い)を分離する
一方で、visitorはデータ構造を一切変えずに、データに対する振る舞いを独立したクラスとして実装するためのものです。
これによって、データ構造の要素自体のクラスを汚すことなく、データ構造の要素に対する処理を好きなだけ追加することができます。
しかし、一方で、データ構造の要素自体に変更・追加があったとき、データ構造の要素に対する処理群全ての具象クラスに影響が波及します。(visitorは全てのデータ構造の要素を把握している必要性がありますからね。)
このことから、データ構造として「多様な型の要素」が階層構造やリストを形成しており、その要素群に対して「コスト計算」「検証」「集計」などの、データ構造の各要素クラスに直接実装したくない処理を頻繁に追加することが予想される場合に有効になります。
Discussion