Golangでデータの書き換えを防ぎたい!

こんにちは、GMOメイクショップの黒木です。

Golangの開発において、こんな経験はないでしょうか?

  • データの整合性を保ちたいのに、書き換えられるリスクが心配
  • コードレビューで、予期せぬ変数の変更箇所を見つけるのに苦労する

従来のGolangでは、標準でReadonly型をサポートしていませんでしたが、ちょっとした工夫で、データの書き換えを防ぐことが可能です。

この記事では、データの書き換えを防ぐ2つの方法と、それらを活用した次世代ECにおける実用例を紹介します。

データの書き換えを防ぎたい場面

Golangで開発を行う際、データの書き換えを防ぎたい場面は多岐にわたります。

データの整合性保持

プログラム内で計算された値や、外部から取得したデータなどを保持する場合、データの整合性を保つことが重要です。しかし、これらのデータが書き換えられると、整合性が失われてしまい、予期せぬ結果を招く可能性があります。

セキュリティリスクを軽減したい

プログラム内で機密情報や個人情報などを扱う場合、その情報が書き換えられると、情報漏洩や不正アクセスなどのセキュリティリスクに繋がる可能性があります。

コードレビューの効率化

コードレビューを行う際、変数の変更箇所を見つけるのは非常に手間がかかります。特に、大規模なコードベースの場合、変更箇所を見逃してしまう可能性も高くなります。

データの書き換えを防ぐ2つの方法

私が考えたデータの書き換えを防ぐ方法は、以下の2通りです。

  1. Readonly型を自前で定義する
  2. 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な値が書き換えられているように見えますが、実際にはそうではありません。

このコードにおける挙動を詳細に説明します。

  1. _readonlyHoge.Value()は、readonlyHoge構造体のValue()メソッドを呼び出します。
  2. readonlyHoge.Value()は、内部に保持する*Hoge構造体のコピーを返します。
  3. _readonlyHoge.Value().Value = "hogehoge"は、返されたコピーのValueフィールドのみを変更しています。
  4. しかし、_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とは

実装内容

  1. envconfigパッケージで環境変数を構造体Envに読み込みます。読み込み時は、Linterで検出されないようにします。
  2. 読み込み時以外に再代入している箇所があれば、Linterを使って検出します。
  3. 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言語に正式に導入されることを期待しつつ、今回紹介した内容が少しでも参考になれば幸いです。