こんにちは、GMOメイクショップの黒木です。
Golangの開発において、こんな経験はないでしょうか?
- データの整合性を保ちたいのに、書き換えられるリスクが心配
- コードレビューで、予期せぬ変数の変更箇所を見つけるのに苦労する
従来のGolangでは、標準でReadonly型をサポートしていませんでしたが、ちょっとした工夫で、データの書き換えを防ぐことが可能です。
この記事では、データの書き換えを防ぐ2つの方法と、それらを活用した次世代ECにおける実用例を紹介します。
データの書き換えを防ぎたい場面
Golangで開発を行う際、データの書き換えを防ぎたい場面は多岐にわたります。
データの整合性保持
プログラム内で計算された値や、外部から取得したデータなどを保持する場合、データの整合性を保つことが重要です。しかし、これらのデータが書き換えられると、整合性が失われてしまい、予期せぬ結果を招く可能性があります。
セキュリティリスクを軽減したい
プログラム内で機密情報や個人情報などを扱う場合、その情報が書き換えられると、情報漏洩や不正アクセスなどのセキュリティリスクに繋がる可能性があります。
コードレビューの効率化
コードレビューを行う際、変数の変更箇所を見つけるのは非常に手間がかかります。特に、大規模なコードベースの場合、変更箇所を見逃してしまう可能性も高くなります。
データの書き換えを防ぐ2つの方法
私が考えたデータの書き換えを防ぐ方法は、以下の2通りです。
- Readonly型を自前で定義する
- Linterで再代入していないかチェックする
今回は構造体Hoge
を初期化後に書き換えNGにすることを目的として、2つの方法をご紹介していきます。
type Hoge struct { Value string }
1. Readonly型を自前で定義する
この方法では、Readonlyな振る舞いを定義したインターフェースと、そのインターフェースを実装する構造体を定義します。
Readonlyインターフェース
// Readonlyな振る舞いを定義したインターフェース type Readonly[type T] interface { Value() T }
- ジェネリック型を用いて、
T
型任意の値を保持するReadonly型を定義します。 Value()
メソッドは、Readonly型が保持する値を取得するための唯一の手段です。
Readonly構造体
// Readonlyインターフェースを満たす非公開の構造体 type readonlyHoge struct { h *Hoge } // readonlyHogeのGetterメソッド func (r *readonlyHoge) Value() *Hoge { if r.h == nil { return nil } copied := *r.h return &copied }
readonlyHoge
構造体は、Readonlyインターフェースを実装します。Value()
メソッドは、内部に保持する*Hoge
構造体のコピーを返すことで、値の変更を防止します。
Readonly型生成関数
// Readonlyインターフェースを返す公開の関数 func (h *Hoge) ToReadonly() Readonly[*Hoge] { return &readonlyHoge{h: h} }
ToReadonly
関数は、*Hoge
構造体のメソッドであり、その*Hoge
構造体をReadonlyインターフェースに変換します。- 内部では、
readonlyHoge
構造体の新しいインスタンスを作成し、引数として渡された*Hoge
構造体のポインタをh
フィールドに格納します。
使用例
var hoge Hoge func init() { hoge = Hoge{Value: "hoge"} } func GetReadonlyHoge() Readonly[*Hoge] { return hoge.ToReadonly() } func main() { _readonlyHoge := GetReadonlyHoge() // hogehogeで上書きする! _readonlyHoge.Value().Value = "hogehoge" fmt.Println(hoge.Value) // 出力:hoge fmt.Println(_readonlyHoge.Value().Value) // 出力:hoge }
上記のコード例において、_readonlyHoge.Value().Value = "hogehoge"
という操作が行われています。
一見、_readonlyHoge
が保持するReadonlyな値が書き換えられているように見えますが、実際にはそうではありません。
このコードにおける挙動を詳細に説明します。
_readonlyHoge.Value()
は、readonlyHoge
構造体のValue()
メソッドを呼び出します。readonlyHoge.Value()
は、内部に保持する*Hoge
構造体のコピーを返します。_readonlyHoge.Value().Value = "hogehoge"
は、返されたコピーのValueフィールドのみを変更しています。- しかし、
_readonlyHoge
変数自体は、元の*Hoge
構造体への参照を保持しているため、元の構造体の値は変更されません。
言い換えると、_readonlyHoge.Value().Value = "hogehoge"
は_readonlyHoge
が持つ別のHoge
構造体のコピーを書き換えていることになります。
利点
- 柔軟性が高い
- さまざまな型に対してReadonly型を定義できます。
欠点
- 汎用的なライブラリとして提供できない
- Readonlyにしたいものごとに、Readonlyインターフェースを満たす実装をしなければいけない
- 再代入してもコンパイルエラーにならない
- 値が上書きされないが、コンパイルエラーにはならない
2. Linterで再代入していないかチェックする
Linterの実装概要
ソースコードを解析し、変数や構造体への代入を検出します。
検出したいコード例
変数への直接代入
hoge.Value = "hogehoge"
Getterを介した代入
GetHoge().Value = "hogehoge"
AST構造の違いによる処理の分岐
代入パターンの違いにより、処理を分ける必要があります。
- 変数への直接代入
ast.AssignStmt
ノードを解析し、ast.SelectorExpr
ノードがast.Ident
ノードである場合、変数への直接代入と判定します。
- Getterを介した代入
ast.AssignStmt
ノードを解析し、ast.SelectorExpr
ノードがast.CallExpr
ノードである場合、Getterを介した代入と判定します。
func run(pass *analysis.Pass) (interface{}, error) { _inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) nodeFilter := []ast.Node{ (*ast.AssignStmt)(nil), } _inspect.Preorder(nodeFilter, func(n ast.Node) { assignStmt := n.(*ast.AssignStmt) for _, expr := range assignStmt.Lhs { selectorExpr, ok := expr.(*ast.SelectorExpr) if !ok { continue } switch selectorExpr.X.(type) { case *ast.CallExpr: if isOverwrittenByCallExpr(pass, selectorExpr.X.(*ast.CallExpr)) { pass.Reportf(assignStmt.Pos(), "do not overwrite") } case *ast.Ident: if isOverwrittenByIdent(pass, selectorExpr.X.(*ast.Ident)) { pass.Reportf(assignStmt.Pos(), "do not overwrite") } } } }) return nil, nil }
変数への直接代入
引数としてast.Ident
ノードを受け取り、そのノードがReadonly
型として定義された変数かどうかを判定します。
func isOverwrittenByCallExpr(pass *analysis.Pass, callExpr *ast.CallExpr) bool { var ident *ast.Ident switch callExpr.Fun.(type) { case *ast.Ident: ident = callExpr.Fun.(*ast.Ident) default: return false } signature, ok := pass.TypesInfo.TypeOf(ident).(*types.Signature) if !ok { return false } for i := 0; i < signature.Results().Len(); i++ { pointer, ok := signature.Results().At(i).Type().(*types.Pointer) if !ok { continue } if named, ok := pointer.Elem().(*types.Named); ok && named.Obj().Name() == "Hoge" { return true } } return false }
Getterを介した代入
引数としてast.CallExpr
ノードを受け取り、そのノードがReadonly
型として定義されたGetter関数かどうかを判定します。
func isOverwrittenByIdent(pass *analysis.Pass, ident *ast.Ident) bool { var named *types.Named switch pass.TypesInfo.TypeOf(ident).(type) { case *types.Pointer: if n, ok := pass.TypesInfo.TypeOf(ident).(*types.Pointer).Elem().(*types.Named); ok { named = n } else { return false } case *types.Named: named = pass.TypesInfo.TypeOf(ident).(*types.Named) default: return false } if named.Obj().Name() == "Hoge" { return true } return false }
利点
- 実コードへの影響が少ない
- 実コードを変更することなく、値の再代入を防げます。
欠点
- 代入パターンの網羅
json.Unmarshal()
やcopy()
など、様々な代入パターンに対応する必要があります。
- 複雑なコードへの対応
- 複雑なコード構造の場合、Linterの実装が複雑になる可能性があります。
次世代ECのプロダクトではこんなことをしています!
Linterを使って、環境変数の安全な管理を実現する
次世代ECでは、envconfigパッケージを用いて環境変数を変数にセットし、様々な箇所で参照しています。
しかし、環境変数はコード上どこからでも変更可能であり、誤った上書きによるバグ発生リスクが存在しました。
そこで、Linterを活用することで、環境変数の安全な管理を実現しました。
※ 次世代ECとは
実装内容
- envconfigパッケージで環境変数を構造体
Env
に読み込みます。読み込み時は、Linterで検出されないようにします。 - 読み込み時以外に再代入している箇所があれば、Linterを使って検出します。
- Github ActionsにLinterを組み込み、CI/CDパイプライン上でデータの書き換えが行われていないかをチェックします。
var env Env envconfig.Process("", &env) GetEnv() *Env { return &env } env.GOPATH = "hoge" // do not overwrite! GetEnv().GOPATH = "hoge" // do not overwrite!
LinterとGithub Actionsによるチェックにより、開発段階で誤った上書きを検知し、バグ発生を未然に防ぐことができました 。
最後に
Golang公式でのReadonly型議論
Readonly型提案がGo issue https://github.com/golang/go/issues/32245 として投稿され、議論が続いています。
2つの方法の比較
今回紹介した2つの方法は、それぞれメリットとデメリットがあります。
項目 | Readonly型を自前で定義する | Linterで再代入をチェックする |
---|---|---|
柔軟性 | 高い | 低い |
実コードへの影響 | あり | なし |
エラー検出 | 再代入してもエラーにならない | Linter実行時にエラーになる |
まとめ
Go言語には、型そのものをReadonlyとして定義する機能は現時点では存在しません。しかし、今回紹介した方法やその他の方法を活用することで、Readonlyな振る舞いを擬似的に実現することは可能です。
状況に応じて適切な方法を選択することで、Readonly型の恩恵を最大限に活かすことができます。
Readonly型がGo言語に正式に導入されることを期待しつつ、今回紹介した内容が少しでも参考になれば幸いです。