goでfunc()(object, func(), error)のようなファクトリー関数の扱いについてのメモ

何かのファクトリーを統一的に扱いたいみたいなことを考えたときにそれへの対応を考えることがある。 goの場合はそれぞれの状況で自分の手でコードを書いてつなげなくてはいけない。ファクトリーに限らないがこの種のバリエーション自体に言及しているのはwireやgo-aws-sdkのlambdaの部分なんかがそう。

それぞれのものが全部同じというわけではないのだけれど、今回は func(...)(object, func(), error) のような関数についてだけ考える(利用例)。

errorを含んだ場合

まず、errorを含んだ場合について考えてみる。例えば、DBというstructのようなものがあったとする1。これを返すOpen()のようなファクトリー関数があることにする。そしてerrorを持たない場合は以下のような感じで使われる。

db := Open(...)
return Use(db, ...)

これがerrorを返すように変更されると、errorハンドリングが必要になる。

db, err := Open(...)
if err != nil {
    return err // Useの戻り値がerrorの場合
}
return Use(db, ...)

単体で考えるとこれだけ。

cleanupを含んだ場合

同様にcleanupを含んだ場合は以下のようになる。特にDBがDBオブジェクトとしてではなく、セッションオブジェクトのようなものになったり、トランザクション(をラッピングしたもの)として扱いたくなった場合にこのような変更が起きる。

db, cleanup := Open(...)
defer cleanup()
return Use(db, ...)

errorとcleanupを同時に返すような場合

さて、ここが本題。errorとcleanupの両方に対応するときにどういう形のコードで組み合わせるべきかで少し悩んだ2

最初の方法はerrorを先にハンドリングする方法。

db, cleanup, err := Open(...)
if err != nil {
    return err
}
defer cleanup()
return Use(db, ...)

完全に同じではないが、net/http.Responseなどを扱っているときのClose()の扱いがこの形。エラーの時にはClose()の呼び出しを気にしなくて良い。

ここでの悩みどころは、取り扱い方を統一しようとしたときに本当にcleanupを呼ばずに済ませて良いのだろうか?ということ。

次の方法はcleanupを先にハンドリングする方法。

db, cleanup, err := Open(...)
defer cleanup()
if err != nil {
    return err
}
return Use(db, ...)

先のdeferを呼ぶことにすれば、必ずcleanupが呼ばれることが保証される。とはいえ、こちらは逆に本当に常にcleanupが呼ばれるのが正しいのだろうか?という疑問がすぐに出る。いや、もっと単純に、戻り値がnilだった時にすぐにpanicするのは危険では?という気持ちになってくる。

あるいはケースバイケースで都度呼び分けてというのは最悪で、消費者に常に負担を強いることになる3

ちょっと改良してnilチェックを加える。これが実は良い形と個人的には思っている。

db, cleanup, err := Open(...)
if cleanup != nil {
    defer cleanup()
}    
if err != nil {
    return err
}
return Use(db, ...)

deferは関数スコープ4なのでifを書いても困らない。エラーに関しても提供者側が気を遣うことでユーザー側が気にしなくても意思表明ができる。具体的には以下のような形。

たとえエラーであってもcleanupを呼んでほしいファクトリー関数においてはcleanupをしっかり返してあげれば良い。

return object, cleanup, err

逆に、エラーの時にcleanupをスキップしてほしいファクトリー関数においてはnilを返してあげれば良い。

return object, nil, err

この場合はcleanupがスキップされる。io.CloserのClose()もこのスタイルで書いてあげれば暫定的には対応できる5

rollback?

ところで、もう少し考えることがある。このような場合はどうだろう。

直接手書きするとこういうことになる。

isSuccess := true
defer func(){
    if isSuccess {
        commit()
        return
    }
    rollback()
}()
if err := Use(db, ...); err != nil {
    isSuccess = false
    return err
}
return nil

このハンドリングもいい感じに扱うことができないだろうか?つまるところファクトリー関数の中でいい感じに扱うことはできないだろうか?というのがちょっとした追加の悩みどころ。

これは暫定的な意見だけれど、context.Contextのようにsync.Onceで囲んだメソッドを用意してそこで制御してあげると良いのかもしれない。

type DB interface { // 別にinterfaceにする必要はないかも?今回は説明のため
    ...
    Failure(err error) error
}

type db struct {
    ...
    transaction *Transaction
    once sync.Once
    err error
}

func (db *db) Failure(err error){
  db.once.Do(func(){
      db.err = err
  })
  return err
}

func Open(...) (DB, func(), error) {
    t, err := newTransaction()
    db := &db{transaction: t}
    if err != nil {
        return db, nil, err
    }
    cleanup := func(){
        if db.err != nil {
            return db.transaction.Rollback(err)
        }
        return db.transaction.Commit()
    }
    return db, cleanup, err    
}

こうしてあげるとUseの中でFailure()を呼ぶ対応する必要があるが、何とかなる。

db, cleanup, err := Open(...)
if cleanup != nil {
    defer cleanup()
}    
if err != nil {
    return err
}
return Use(db, ...)

// func Use(db DB, ...) error{
// ...
// if err := cont(...); err != nil {
//    return db.Failure(err)
// }

別の方法としてはcallbackに対してerrorbackを返すようにするというものだけれど、これはもう使い手側が厳しくなってくると思う。

暫定的な意見の理由としては、Failure()を呼ぶのがだるくない?呼び忘れしそうじゃない?というのと本当に最初のエラーを捕まえるので良いの?というところ。

さいごに

ところで、こういう変更が一番めんどくさい(当初の値で良いのでmainで一回作成して渡しておけば良いよねというのがウソになり)。そして、この種の部分が一番先に見越して対応した気になっているとだるくなるだけの部分な気がしている(全部のコンポーネントをセッションとみなす対応はだるい)。

単純に言えば誰かがいい感じに代わりにやってほしい。


  1. interfaceかもしれない。その辺については言及していない。

  2. ちなみにどういう形式で返すべきかということには悩みはない。errorが再右端というのはlintを有効にしていればわかる。通常戻り値は最左端になるので、(object, func(), error) 以外考える必要はない。

  3. まぁそれでも良いという意思決定が随所にみられるのがgoという気もするが。正確に言えばその部分の自由度は最大にしておきたい的な意味合い。

  4. この言葉の定義が正しいかちょっと怪しい。言いたいのはブロックスコープではないのでifで囲っても特に困らないみたいなこと。

  5. Close()の失敗の対応がちょっと面倒。ログに出力するだけにして良いのなら。

awsのassume roleに対応していなさそうなコマンドはaws stsを使わずともAWS_SDK_LOAD_CONFIG=1を付けて実行すれば良いかも?

以前の記事の続編ということになりそう。

ほとんど内容はタイトル通りで、assume roleに対応していないようなコマンドがある。それを使うにはどうすれば良いかという話。

以前の記事では aws sts assume-role で頑張って一時的なsession tokenを作成して実行ということをしていたのだけれど、これをライブラリ側に任せてしまえる環境変数があることを知った。具体的には AWS_SDK_LOAD_CONFIG=1 をつけて実行すれば良い。

詳細

ドキュメントの Sessions with a Shared Configuration File の部分を見れば良い。

Sessions with a Shared Configuration File

Using the previous method, you can create sessions that load the additional configuration file only if the AWS_SDK_LOAD_CONFIG environment variable is set. Alternatively you can explicitly create a session with a shared configuration enabled. To do this, you can use NewSessionWithOptions to configure how the session is created. Using the NewSessionWithOptions with SharedConfigState set to SharedConfigEnable will create the session as if the AWS_SDK_LOAD_CONFIG environment variable was set.

assume roleに対応するには、shared configurationを有効にしなければいけないのだけれど。AWS_SDK_LOAD_CONFIG=1 などとしておくと、これを環境変数経由で有効にできるという話。

例えば、aws-s3-proxyなどで実行するときに以下のようにしてあげれば良い。

pottava/aws-s3-proxy: Reverse proxy for AWS S3 with basic authentication.

$ MY_BUCKET=<s3 bucket name>
$ AWS_SDK_LOAD_CONFIG=1 AWS_S3_BUCKET=$MY_BUCKET DIRECTORY_LISTINGS=1 DIRECTORY_LISTINGS_FORMAT=html APP_PORT=8080 DISABLE_COMPRESSION=0 ACCESS_LOG=true HEALTHCHECK_PATH=/_health aws-s3-proxy

memo: aws-sdk-go でassume roleを有効にするコード

これはメモ程度のもの。goでコードを書いている時などに、先ほどの環境変数の指定なしにassume roleに対応するにはCreate Sessions with Option Overridesに書いてある通り以下のような形でsessionを作ってあげれば良い。

// Force enable Shared Config support
sess, err := session.NewSessionWithOptions(session.Options{
    SharedConfigState: SharedConfigEnable,
})

参考