emahiro/b.log

Drastically Repeat Yourself !!!!

DynamoDB のリクエストで Context Canceled をハンドリングする

Overview

タイトルの通りです。 DynamoDB へのリクエストにおいて Context Cancel エラーになった場合にその Context Cancel をハンドリングします。

DynamoDB 側でのエラーハンドリング

DynamoDB へのリクエストにおいて Context Cancel の実装は https://pkg.go.dev/github.com/aws/smithy-go#CanceledError.CanceledError でされています。

CanceledError returns true to satisfy interfaces checking for canceled errors.

Godoc にも canceled の時に返されると記載されているので、最終的にこの Context cancel エラーをハンドリングしたい箇所で取り出してハンドリングできれば良さそうです。

コードを追っかけていきます

実際に SDK のコードを追っていきます。

Query() 関数をサンプルとして見ていきます。

Query 関数の内部で invokeOperation を 呼び出していて、この invokeOperation の中での smithy-go 内部にある middleware.DecorateHandlerHandle メソッドを Call します。このい Handle メソッドで発生してエラーは invokeOperation メソッドでは OperationError として返されます。 Handle メソッドのエラーが OperationError に入れられて返されることになります。

次にこの Handle の実装以下を追っていきます。

この Handle メソッド内部で Middleware に実装された HandleMiddleware を Call しており、これは BuildStep に実装されたHandleMiddleware を Callします。
さらに、この実装の内部で buildWrapHandler が実装してる HandleBuild を Call しており、HandleBuild 内部の buildWrapHandler.Handler に実装されている Handle メソッドで実際に AWS 本体と通信してる HTTP Client に実装された Handle メソッド の内部で、smithy-go.CanceledError返してる箇所があります。

これがエラーを返してる本体です。

実際のエラーハンドリング方法

いくつかやり方があると思います。一番単純なのは Dynamo から返されたエラーの文字列の中に context canceled が含まれていればヨシ!とする方法です。
ただし、エラーの文字列一致はそれじゃないと対応できない場合を除いて極力採用するべき方針ではありません。

できることであれば Go のエラー方をそのまま使って errors.Is でハンドリングしたいです。Go のエラーはエラーが返される過程で fmt で wrap されている or 独自エラーで wrap されている限り errors.Is でエラーの判別をすることが可能です。

※ どこかでエラーを書き換えてしまってる場合、正常にハンドリングできません。

実際にエラーが取り出せるかをキャストでやって見ました -> https://play.golang.org/p/KPPzJgqspIk
これは trueが返ってきました(つまり context canceled が発火したということ)

次に errors.Is でハンドリングできるかやって見ました -> https://play.golang.org/p/vT4anMduCL7
※ 余計なエラーで書き換えてない場合のみ。途中に errors.New があったりして元々のエラーからさらに書き換えていると Is で取り出すことはできません。

まとめ

エラーハンドリング、、されどエラーハンドリング。
安易に文字列比較に逃げることなく、Error が定義されてるはずだ、と見通しを立てて実装するって大事だなと思いました。

追記

AWS のリクエストを送信する invokeOperation メソッドが返す smithy.OperationErrorUnwrap実装してる ので、AWS 内では発生した何かしらの OperationErrorerrors.IsUnwrap して取り出すのが良いです。

OperationErroroperation error {$ResourceName}: {$OperationName}, https response error... という文字列で返ってくるエラーのことです。

dynamodbattribute には omitempty が使える

ただの備忘録です。

dynamodbattribute には omitemtpty タグが使えてStruct の中でゼロ値になってしまうところを無視したい(Item の attribute として登録したくない)ケースにおいては使えるなと思いました。

https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/dynamodbattribute/#Marshal

The omitempty tag is only used during Marshaling and is ignored for Unmarshal. Any zero value or a value when marshaled results in a AttributeValue NULL will be added to AttributeValue Maps during struct marshal. The omitemptyelem tag works the same as omitempty except it applies to maps and slices instead of struct fields, and will not be included in the marshaled AttributeValue Map, List, or Set.

Loop 内の値渡しと参照渡しで久しぶりにハマった話

Overview

Go の loop 処理の中で Slice の中の全値を書き換えたいと言う処理、において参照渡しと値渡しを取り違えて、意図した処理をできずにバグを生んでしまったのでその懺悔のための備忘録です。

何が起きたか?

以下の処理でSliceの全ての ID フィールドを書き換えることはできません。

type X struct {
    ID int
}

func main() {
    x := []X{{ID: 0}, {ID: 0}, {ID: 0}, {ID: 0}}
    for _, v := range x {
        v.ID = 1
    }
    fmt.Println(x)
}

ref: https://play.golang.org/p/wTDbQzO0AwQ

以下の処理では値を書き換えることができます。

type X struct {
    ID int
}

func main() {
    x := []X{{ID: 0}, {ID: 0}, {ID: 0}, {ID: 0}}
    for i := range x {
        x[i].ID = 1
    }
    fmt.Println(x)
}

ref: https://play.golang.org/p/TVHROj7vWD1

原因: 実態のコピーを上書きしていたので大元の slice の struct の値が書き変わらなかった

1 つ目の実装は struct の slice なので loop の中の v は実体がコピーされて毎回同じメモリ空間に同一の値を書き込み続けますが、これは実体のコピーなので書き換えることができません。
要素を struct の参照にすれば書き換えることができます。

参照型の slice じゃないパターンでは各 loop におけるポインタが不変でそこに対して値を上書きしてしまっています。ただし、この場合、loop 内の struct は全く別のものとして扱われ、別参照に対してずっと値を上書きしようとしてるに過ぎず、元の slice の中身は書き変わらりません。
参照型の slice だと書き変わるのは loop 内で参照する値が参照型で、それが指し示す先の slice も書きかわります。
(これが元で参照の slice の loop の中で値の書き換えを行うと、全部同じ値になってしまうのがいわゆる for loop pointer 問題です。)

ref: https://play.golang.org/p/cCgEOYGrnmC

実際に playground での挙動は以下です。

カスタム struct の slice の場合は別参照に対して書き換えていることがわかり、大物 slice は何も更新されていないことがわかります。

https://play.golang.org/p/k5HOsjGR--b

一方で Index を指定したときは以下です。

https://play.golang.org/p/COgCsWlSc5F

大元の slice の各要素のポインタに対して値を書き換えています。

まとめ

久しぶりに初心者みたいな実装ミスして、改めて slice の loop 内での挙動を確認してみました。

追記

20210531

https://github.com/golang/tools/blob/master/gopls/doc/analyzers.md#unusedwrite を教えてもらいました。

The analyzer reports instances of writes to struct fields and arrays that are never read. Specifically, when a struct object or an array is copied, its elements are copied implicitly by the compiler, and any element write to this copy does nothing with the original object.

今回みたいな original の slice に上書きできないケースを検知して警告してくれます。早速 VSCode で設定しました。

"gopls": {
        "analyses": {
            "unusedwrite": true, // NEW
        },
}

DynamoDB の Query with FilterExpression の挙動を調べた話

Overview

DynamoDB の Scan の挙動について以下のエントリで調べましたが、FilterExpression を指定した場合の Query の挙動について実際に API で Query を叩いた場合と、AWS Management Console で表示したときで挙動が異なっていたので、ついでに調べた備忘録です。

ema-hiro.hatenablog.com

どういう挙動だったか

ある GSI を Key に指定し、Filter Expression を追加して Query API を叩いて取得した Item の Count 数と AWS Management Console 上で返ってくる Item の個数が、同じ Filter Expression を使っていた、同様のクエリのはずなのに異なっていた、という挙動です。

Conclusion

結論から先に言ってしまうとこれは Scan について調べたときと全く同じでした。
Scan のときと同様、AWS Management Console 上で DevTool を開いてレスポンスを調べた結果が以下です。

f:id:ema_hiro:20210521040042p:plain

どういうことかというと、Filter した結果が Count に入っていて、Count で指定した分を UI でリクエストされる分だけ繰り返し Fetch してた、ということでした。

DynamoDB のクセ的なもの

ここで分かったのは Scan にしろ、Query にしろ、ある母集団の結果を返す、という挙動は同じであり、それが全件走査結果なのか、Partitioning された結果なのかの差は関係なかったです。

そしてもう1つは DynamoDB の Filter Expression は文字通り フィルタ であったことです。
1リクエスト当たり取得した母集団に対して、DynamoDB 側でフィルタをかけて、要素数間引いてる だけでした。言ってしまえば、クライアント側で絞り込みを実装してるようなものですね。それを DynamoDB の API の中でやってくれている、という挙動でした。

Filter Expression はフィルタであってクエリでない、この理解を間違っていて、意図した結果をクライアントに返せない実装になってしまってました。

DynamoDB について学んだこと

ここ数日 DynamoDB を永続的なデータストアとして使ってるケースにおいて、色んなクエリを叩けるわけではなく、基本的に全件走査を避けつつ、要求される仕様の中でどんなクエリが叩かれるのか?言い換えると、そのユースケースを実現するためのクエリのパターンは何か?ということを DynamoDB を使う上では常に意識する必要がある、ということです。

全然柔軟じゃないなーと思う反面、何かユースケースを追加する依頼が来たときにどれくらいの本気度でユースケースを考えているのか?を確認するモチベーションになります。どれくらい雑でいいのか?ちゃんと厳密に色んなケース(それこそクエリだったり、結果として返ってくるデータセットの並び順など含め)を想定しないといけません。

DynamoDB は GSI に PK/RK というかなりキツイ制約があるので、このすり合わせのモチベーションを高く持てるのがいいところだなと思いました。DynamoDB に限らず、NoSQL だったり NewSQL と呼ばれるものを使う場合は、ユースケースを実現するためのクエリパターンをデータ設計の段階から頭に入れておくことが必須ですね。

DynamoDB の Scan の挙動がわからなかったので調べた話

Overview

DynamoDB の Scan 操作について AWS のマネジメントコンソールと API 直叩きで返ってくる List が違う、ということがあったので調べてみました。

どう違ったのか?

以下のような事象が起きてました。

  • AWS マネジメントコンソール上からあるテーブルに条件 (Filter) を追加して Scan 操作をすると 50 件返ってくる。
  • 同じ条件で AWS CLI から Scan 操作をするとデータが返ってこない。

何が起きていたのか

多分 AWSCLI(要は API 直叩き)の方が一度の Scan 結果としては正しいと思ったので AWS マネジメントコンソールで何が起きてるのか DevTool のネットワークからわかるかなーと思って徐にみてみたところ

f:id:ema_hiro:20210513124746p:plain

マネジメントコンソールで50件表示されていたのは 50 件取れるまで何度も操作をし直していたからでしたw

つまりマネジメントコンソール上では、Scanの操作に対して UI 上は 1 度しか叩いてないように見えても実態は

  1. 一度APICall投げる(countに達しない)
  2. もう一回 Call投げる

1->2->1…で count に達するまでやり続ける、ということをしていたようです。node に分散してるデータを片っ端から取りに行って View で表示してくれてるだけでした。
考えてみれば当たり前なんですがw。目的の FIlter したい条件を一発でとってくるには Index 設定する他ないですね。

ぼやき

ここはぼやきですが、ずっと GCP の Datastore/Firestore に慣れ親しんできて DynamoDB を触るようになって半年くらい経ちますが、全然同じ感覚で使えない(運用してみて、どちらかというと RDB 触っていた頃に感覚としては近い?)なぁと最近感じてます。
良し悪しがあるので、どっちがいい、という話ではないですが、Index の考え方だったり、最低限のクエリ叩く方法だったり、GCP の方が直感的な感じはありますね(個人の感覚です)

終わり。

DynamoDB で Update GSI しようとしてやらかした話

Overview

タイトルの通りなのですが、DynamoDB の Global Secondary Index(以下 GSI) を更新するにあたり、更新方法でミスったのでことの顛末を備忘録として記載しました。

何をしたのか?

terraform で DynamoDB の構成を変更する、今回はすでに運用されてる DynamoDB の Table に対して GSI を更新するというオペレーションを実行しました。

resource "aws_dynamodb_table" "hoge_table" {
  hash_key     = "$HashKey1"
+ range_key    = "$RangeKey"
}

+attribute {
+   name = "$RangeKey"
+   type = "S"
+}

global_secondary_index {
   name            = "AlreadyExistIndex_1"
   hash_key        = "$HashKey1"
+  range_key       = "$RangeKey"
   projection_type = "ALL"
}

global_secondary_index {
   name            = "AlreadyExistIndex_2"
   hash_key        = "$HashKey2"
+  range_key       = "$RangeKey"
   projection_type = "ALL"
}

terraform の定義ファイルとしては上記のような感じで構成して terraform apply しました。

何が起きたか

GSI を更新するときに Index の recreate が発生し、すでにアプリケーションで使用していた AlreadyExistIndex_1 を Key にした Query オペレーションが ValidationException: The table does not have the specified index: AlreadyExistIndex_1 status code: 400 ... で落ちてアプリケーションが正常に動作しなくなりました。

どうして起きたか?

GSI を複数?(複数が関係してるかどうかわかりませんが、)更新するにあたり delete -> create の順で Index の更新が発生し、先に AlreadyExistIndex_1 と AlreadyExistIndex_2 が削除され、改めて Create されるというオペレーションにおいて AlreadyExistIndex_1 をアプリケーションが使用していたために、Create してる最中にもアクセスしてしまい 400 エラーが発生してしまいました。

公式のドキュメントにも

You can create or delete only one global secondary index per UpdateTable operation.

ref: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTable.html#API_UpdateTable_RequestParameters

とあり、更新がタイミングごとに GSI の Delete と Create のオペレーションは1度ずつしか呼ばれません。

これだけだと即時で入れ替えがされそうにも読めますが、今回の挙動とこのドキュメントを参照する限り GSI の更新にあたり UpdateTable で既存の GSI を更新するときにはまず先に Delete Operation が実行され、その後に Create がされるようです。その後に Create がされるようです。つまり Terraform Apply で複数の Index を更新する場合、処理としてはそれは可能だけど既存の Index を全部先に削除(= 更新対象の 2 つとも削除)し、その後新しく Index を生成する、という挙動になるようです。

先に全削除が走ってしまうため、削除したタイミングで AlreadyExistIndex_1 をアプリケーション側で参照していたために、アクセスしてしまいエラーが発生した、と言うのが一連の流れでした。

今後どうすればいいか

すでに運用されてる GSI の更新をする場合には以下の手順で行うといいと思います。

  1. 変更先となる Index を新しい Index として定義して先に Create。
  2. aws dynamodb query ... コマンドを使って新しい Index が意図する動作になってるか確認する。
  3. アプリケーションの参照してる Index を新しい方に変更。
  4. 更新前の Index を Delete。

RDB を運用してる時と似てますね。

まとめ

定期的にやらかすことがあるんですが、今回は久しぶりに知識として知らないまま操作を行なってしまって焦りました。
DynamoDB についてまた一つ詳しくなることができました。

Lambda の同時実行数を制限する

Overview

タイトルの通りです。
久しぶりに Lambda on Serverless Framework を触っていて忘れていたので備忘録です。

Conclusion

Lambda の同時実行数を制限するには Reseved Concurrency の設定を 1 にします。
Serverless Framework は function の reservedConcurrency の設定で同時実行数を調べます。

www.serverless.com

Lambda の同時実行回数について

ReservedConcurrency と ProvisionedConcurrency について

以下のドキュメントにその答えが書いてあります。

docs.aws.amazon.com

Reserved concurrency – Reserved concurrency creates a pool of requests that can only be used by its function, and also prevents its function from using unreserved concurrency. Provisioned concurrency – Provisioned concurrency initializes a requested number of execution environments so that they are prepared to respond to your function's invocations.

Reserved concurrency は Lambda (の起動)で使用することができるリクエストのプールを作成し、予約されていない並行のから Lambda の起動を防ぐことできます(= Lambda の並行処理を制御できる )

対して、Provisioned concurrency はリクエストされた実行環境においてあらかじめて Lambda の発火に対応できるように環境を用意しておくことです。

同時実行回数については上記の説明から、reserved concurrency を利用すればいいことがわかりますが、さらに以下で詳細に説明されています。

To ensure that a function can always reach a certain level of concurrency, configure the function with reserved concurrency. When a function has reserved concurrency, no other function can use that concurrency. Reserved concurrency also limits the maximum concurrency for the function, and applies to the function as a whole, including versions and aliases.

並行性のレベル(程度)に達したいとき(= どれくらい並行で実行するのか制御したいとき)は reserved concurrency を利用します。関数は reserved concurrency が設定されてる時はその設定された並行数以上に発火することはありません。reserved concurrecy は最大並行実行数の上限であり、version やエイリアスに渡って全ての Lambda に適用されます。

まとめ

久しぶりに触って色々忘れてましたが、とりあえず迷ったら Serverless Framework の設定を見る前に Lambda の GUI の設定を見ろ でした。
もちろん生の Lambda の設定が Serverless Framework 上でどうマッピングされているのかは理解しないといけませんが、Serverless Framework は Lambda の設定を透過的に使えるものであって、いわばラッパーなので大元を見ないといけないですね(何を当たり前のことを...)

その他

全然本論と関係ないですが、SQS で Lambda を Trigger をするときに Lambda 側でエラーを返さない(=正常終了した)場合に限り、SQS に Queue は戻され成功する(or Queue の有効期限が切れる)までなども Lambda が実行されますが、その Worker で拾われる周期は Visibility Timeout が切れるまでです。
Visibility Timeout 以内であればエラーを起こしても Worker では拾われません。

chi で独自の middleware 使う

Overview

chi で独自 middleware を使う上で使い方を忘れていたのでその備忘録。

備忘録

router.Use(func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // middleware でやりたい処理
        next.ServeHTTP(w, r) // これがないと次の router に処理が伝播しない
    })
})

middleware 内部で ServeHTTP しないと実際の処理をしてる handler まで処理が伝播しないことを忘れていて時間を溶かしてしまった。

ちなみに以下のような感じで前処理と後処理に分けることができる。

router.Use(func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // handler の前処理
        next.ServeHTTP(w, r)
        // handler の後処理
    })
})

AWS Go SDK V2 を使ってみる

Overview

AWS Go SDK の V2 が GA になったので早速業務の中で使ってみてハマったところ、V1 との違いでつまづいたところを中心にまとめます。

基本的には https://aws.github.io/aws-sdk-go-v2/docs/migrating/ にまとまっていますが、色々わからないところがあったので補足しながら記載します。

AWS SDK for Go V2 について

1月に GA になっています。

aws.amazon.com

github.com

AWS の設定をロードする

一番最初からなんですが、一番ハマったのはここでした。

AWS SDK for Go を利用する場合、AWS の認証情報をとって設定をロードし、それを各サービスのクライアントに当てはめて AWS の各サービスの API を Call することになります。

単純な Configuration のロードと V1 → V2 へのマイグレーションについては https://aws.github.io/aws-sdk-go-v2/docs/migrating/#configuration-loading に記載されている通りです。 特に独自に環境変数を持っていないケース(AWS Lambda とか?)ではここのマイグレーションドキュメントの通りに実装すると良さそうです。 (Lambda ではまだ実装してないので多分ですが。そのうち Lambda でも使おうと思います。)

独自で環境変数を持ってる場合

自前で AWS環境変数を設定してるケースでは、https://aws.github.io/aws-sdk-go-v2/docs/migrating/#credentials--credential-providers に書かれている Credentials を取り出してから Config を Credentials 付きで Load します。

id := os.Getenv("$YourAccessKeyID")
secret := os.Getenv("$YourSecretKey")
// AWS_SESSION_TOKEN はそのまま取り出してOK
cred := aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(id, secret, os.Getenv("AWS_SESSION_TOKEN")))
if cred == nil {
    panic("failed to fetch credentials provider")
}
cfg, err := config.LoadDefaultConfig(ctx, config.WithCredentialsProvider(cred))
if err != nil {
    panic(err)
}
// => ここで load した config を AWS の各サービスに入れて使います。

DynamoDB を使う

仕事で使ったのは DynamoDB だったので DynamoDB を例にとって V2 を使った DynamoDB の操作方法について記載します。

DynamoDB の使い方のサンプルは公式が以下のドキュメントを公開しています。

aws.github.io

DynamoDB クライアントを Load する

これは割と簡単で上記で取り出した config 情報を DynamoDB のクライアントに差し込みます。

dynamodb.NewFromConfig(cfg)

リソースを更新する

サンプルとしてリソースの更新する実装は以下です。

update := expression.Set(expression.Name("key"), expression.IfNotExists(expression.Name("key"), expression.Value("val")))
// SET a = :a, b = :b のように複数 key を更新したい場合は以下のように Set を追加します。
update = update.Set(...)

expr, err := expression.NewBuilder().WithUpdate(update).Build()
if err != nil {
    return err
}

input := dynamodb.UpdateItemInput{
    TableName: tableName,
    Key: map[string]types.AttributeValue{
        "$PK": &types.AttributeValueMemberS{ 
            Value: "$PKValue",
        },
    },
    UpdateExpression:          expr.Update(),
    ExpressionAttributeValues: expr.Values(),
    ExpressionAttributeNames:  expr.Names(),
    ConditionExpression:       expr.Condition(),
}

これに対応する aws dynamodb のクエリは以下です。

aws dynamodb update-item \
--table-name $tableName \
--key '{"$PK":{"S":"$PKValue"}}' \
--update-expression "SET key = :val" \
--expression-attribute-values '{":val":{"N":"1"}}' \
--return-value ALL_NEW

ちなみに --key に GSI を指定できないことに気づかずだいぶ時間を溶かしました。ドキュメント読んでて Id を指定しているのをみて気づきました。

※ クエリの表現は以下に大体書いてあります。

docs.aws.amazon.com

expression はまだ feature だった

V1 で存在していた dyanmodb/expression が V2 だと最初見つからずに、愚直に expression を書くしかないのかなーと思ってたのですが、feature package の中にいました。

余談: 毎回めんどいなーところと思うところ

DynamoDB のクライアントの実装してて毎回 aws コマンドぽちぽちしながら一個一個 expression のフィールドに当てはめて実装していくのが結構めんどいなーと思ってます。

毎回こんな感じでちまちま aws dynamodb コマンドを叩いて少しずつ実装をしてます。

aws dynamodb query \
--index-name $indexName \
--table-name $tableTame \
--key-condition-expression "key = :val" \
--expression-attribute-values '{":val":{"S":"$Value"}}'

Transaction について

DynamoDB のトランザクションについてまだちゃんと見てないですが使う機会がありそうなのでその時に調べようと思います。

ざっと調べた感じ例えば Put の処理に対して Transaction を貼る場合はhttps://github.com/aws/aws-sdk-go-v2/blob/67f74949e4831edc2d0a8da2aba8c4b356b27fff/service/dynamodb/api_op_TransactWriteItems.go#L95 の TransactionItems に Put したいテーブルの Attributes を突っ込んでいって一括で処理するっぽいですね。

Put の中身自体は https://github.com/aws/aws-sdk-go-v2/blob/67f74949e4831edc2d0a8da2aba8c4b356b27fff/service/dynamodb/types/types.go#L1876-L1905 の構造体に定義があるのでこれを一個一個作って突っ込んでいくのが良さそうです。

以下で仕様周りのことは記載してあります。

docs.aws.amazon.com

ここはまた実装してみた後にまとめたいと思います。

See Also

strings.Split は分割できない時でも長さ1の slice を返す。

https://play.golang.org/p/ss6gWvidE2u でなんで長さ 1 の slice が返ってくるんだろーって悩んでたのですが、、、

// Split slices s into all substrings separated by sep and returns a slice of
// the substrings between those separators.
//
// If s does not contain sep and sep is not empty, Split returns a
// slice of length 1 whose only element is s.
//
// If sep is empty, Split splits after each UTF-8 sequence. If both s
// and sep are empty, Split returns an empty slice.
//
// It is equivalent to SplitN with a count of -1.
func Split(s, sep string) []string { return genSplit(s, sep, 0, -1) }

Godoc にもちゃんと 1 が返ってくるって書いてあるし、そもそも https://pkg.go.dev/strings#Split はsep に指定した文字列で分割できない時は s に指定した文字列を slice にしてそのまま返す、って言う当たり前の振る舞いを忘れてただけでした。

Slack のスレッド利用「原則非推奨」という実験をやってみた

Overview

僕の担当してるプロジェクトの Slack チャンネルで1週間限定で スレッド利用原則非推奨 というルールを作って運用してみた振り返りです。

プロジェクトに関わるメンバーに協力してもらって以下のルールで1週間 Slack を利用してみました。

  1. 全ての投稿はチャンネルへの投稿する。ある依頼への返事やMTGに遅刻してる人を呼ぶDM的な使い方も含む。
  2. Slack の流量が多くなることが想定されたので、気づいて欲しい時は臆せずメンションをつけること。

他部署や他プロジェクトの人に実験を強制するわけにはいかないので、その場合は例外的にスレッドの利用を許可しました。ただし、プロジェクトメンバーがスレッドで発言するときは also send to $Channel を必須にしてもらい、回答がチャンネルに流れるようにしてもらいした。

背景

そもそもこんな実験をしようと思った背景についてですが、僕は Slack に「スレッド機能」が登場した当初から Slack には不要な機能だなと思っていました。この点に関しては、もしかしたら同じような考えを持ってる人が一定いるかもしれません。

Slack (に限らず、こういったチャットベースのコラボレーションツール) に対しての僕の基本的な考えは以下です。

  • Slack は情報のフローであって情報をストックするツールではない。
  • スレッドは通常のチャンネルから UI 上 1 階層深いところにあるため、大量のチャンネルに入っていたり、プロジェクトを跨いで仕事をしてる人(要は忙しい人)ほど、深いところの情報を追うのが億劫になって見なくなりがち(僕はできることなら見に行きたくない)
  • 見るのが億劫になることで、意思決定する人が検知しないところで話が進んでしまう、みたいな潜在的なリスクを孕んでいる。
  • スレッド内で話される内容は当たり前だがコンテキストが高い内容が多くただでさえ、自身の意識を奪われがち、かつ会話の開始タイミングから間が空いてるものを急に掘り返されたりするケースがあるとコンテキストスイッチが変えるコストに加えて思い出しコストが乗ってきて、マジでキツい。
  • 「情報は流れていってしまうもの」という認識をチームで統一することで、ドキュメントに残す(情報を別の場所にストックする)インセンティブ(※)をより働かせる。

※ 僕が担当してるプロジェクトではプロジェクト間で共有するべき情報はコンフルに、プロジェクト内の開発に関係するメンバーは Figma(or Whimsical) に仕様・議論の経過などを一元的にストックするようにしています。

振り返り

3/4~12 までの1週間、このルールを回してみて、昨日ちょうど振り返りをしたので、協力してくれたメンバーのフィードバックをまとめると大体以下のような感じでした。

Good

  • プロジェクトに関連する情報がチャンネルのファーストビューに流れるので、拾い読みがしやすく、ながら時間でのキャッチアップがしやすい。
  • ハイコンテキストな会話が他部署とのやり取りに制限されて負担は減った。
  • 考察とかの会話重ねて練度上げていく投稿がスレッドじゃなくてチャンネル上に存在するので、チャンネル閲覧者の情報理解度が上がった。
  • ざっと、他の人が何しているかわかり透明性が高くなった。
  • スレッドに潜らなくても主要なやりとりは可視化された。

etc...

More

  • スクロールが長い。
  • チャットがあるアジェンダで盛り上がってるときに別の話題で投稿しづらい。
  • 数往復程度のやりとりや単一のアジェンダであればスレッドにするまでもないが、議論の足が長かったり、複雑な内容の会話になる場合はスレッドの方が向いてる。
  • 他部署絡むとスレッド作った方が効率的な場合がある。
  • Slack の機能的な制約により厳しい側面がある(発話元リンク投下しても見づらいし、検索機能がいけてないので後追いに時間がかかるなど...)

etc...

以上が振り返りです。

個人的な所感

Good/More を元にして個人的に想定していたこと、想定していなかったことについて記載します。

まず振り返りをして思ったのは、期間限定とはいえコミュニケーションの仕方をある種変えることを強制することになるので、反発やストレスがあるかなと思ったんですが、プロジェクト内のメンバーから概ねポジティブなフィードバックが多かったのが意外でした。
この辺はもしかしたら普段から大量の情報に触れてたり、ある程度今何がどうなっているのかを主体的にキャッチアップしようとするメンバーが多いという恵まれた状況にあったからかもしれませんが、多少の混線やUI的な見づらさはあれど、致命的にネガティブなものはなく、キャッチアップコストの低減と透明化の観点からはスレッドに使わずチャンネルにどんどん投稿した方がいいのかなと思います。事実、チャンネル投稿を推奨しても生産性が大きく削がれた、というフィードバックはありませんでした。

スレッド自体も機能の1つなので、発話者が使った方が良い、と思えば使うことはいいと思います。あえて強制力のあるルールを作る意図もありません。 ただ、迷ったらチャンネルに投稿しておく方が無難だし、公開したい範囲を狭める必要がないならチャンネルに投稿しておくのがいいと思います。

こういうコミュニケーションについて考えているとやたら関係者や話題と絞りたがる欲求が強いなと感じる場面が多いのです。ただ、情報の取捨選択は受け取る側でやるべきことなので、とりあえず迷ったら垂れ流しておいてくれる方が助かる場面は多いです。
Good にも記載してますが、「拾い読み」の効用は大きいなと感じるケースは多いです。たまたま気になるトピックを見つけたことから話が広がってアイデアに繋がることや、逆に事前にブロッカーを取り除けたり、リスクに気づけたりする機会も生まれたりすることは、僕は貴重だと思います。

何よりリモートが前提となってる今だからこそ、チャンネルを賑やかにさせる空気感?みたいなものを作るためにも、どんどんチャンネルに投稿した方がいいのでは?という気すらしてきます。

そういえば、この実験してる最中に Slack を作ってる Slack 社では Slack をどのように使っているのか?とナイスタイミングでまとめてくれてるエントリがありました。

qiita.com

なお、エントリの中で引用されているメルカリのガイドラインは本当に秀逸で、Slack を使う場合は全てこのガイドラインが最低ラインであり基準になると思っています。

mercan.mercari.com

コミュニケーションコストについて

ここで改めてコミュニケーションコストというところについて考えてみたいと思います。

チャンネルへのそのまま投稿にしろ、スレッドにしろ、目指したい姿は 実行のスループットを上げるためのコミュニケーションコストの低減 であるべきです。

ハイコンテキストな内容であれば、関係者間で話を進めた方が早いでしょう。それは事実だと思います。ただし、それはともすると公開される場所に置いて意図的に部屋を分ける行為(言い方を変えるとある種の密室状態)になります。Slack のスレッドは密室じゃない、という意見もありそうですが、1階層深いところで話されている時点で本来知るべきメンバーにも公開されていない or 自分で取りに行かないといけないわけで、それが密室でないとは僕自身は思いません。
ある程度話が進んだ段階で、あとから部屋に呼ばれた時の「いやそれは聞いてねーよ」みたいなことが発生する可能性を孕んでることが、本当に目指したかったところに繋がってるのかは個人的には疑問です。
僕自身もそこまで多くはないですが、全然違う仕事をしていたときに、いきなりかなり会話の進んだスレッドに巻き込まれて、話について行くのに時間がかかったことが、この1年だけでも何回かあります。

こういうことが起きそうだなーと考えたとときに、「誰でもみれる場所に大量に流れている情報から必要なものを取り出し整理する状況」と「準密室でコンテキストが限定された会話の内容を自分で取りに行かないといけない状況」を比較したときに、僕自身は前者に振る方が全体としてはコミュニケーションコストが減ると考えています。

最後に

個人的には全ての情報がチャンネルに垂れ流されてるという状態は、情報のキャッチアップコストの低下、及びプロジェクト内の透明性の観点からありがたかったのですが、一方で大量の情報から必要な情報をぱっとピックアップして、整理できるというのは、それ自体が個人の能力に依存したスキルの1つと捉えるようになりました。
ありがたいことに、スキルは後天的的につけることが可能です。

そしてこの大量の情報の流れの中から自分に必要な情報や気になる情報をパッと取り出すスキルの習得にうってつけなのは 間違いなく Twitterです。
仕事の生産性向上のために、業務中とか関係なく全力でTwitter をやって行きましょう。Twitter するのも仕事のうちです。

osimai

参照型変換メソッドを func で表現する

Overview

タイトルの通りなんですが、Go の struct をするときにその struct のあるフィールドを参照型にすることがあると思います。
この参照型のフィールドに値を代入するために、プリミティブな型を参照型に変換するだけの関数を作成することがあるかと思いますが、そのために unexported な toIntPtr みたいな関数を作成するのではなく func を使って代入することができるなーと思ったのでそのことについて書いてみます。

どう言うときに使うの?

主にテストを書くときのことを想定しています。

例えば、時刻を扱う表現で MySQL の DATETIME 型の初期値を NULL にしたいケースではフィールドの型を *time.Time にするケースは多くあります。こういった struct を使ったテストをするときにシンプルに時刻を生成してフィールドにアサインしようとするとコンパイルエラーが発生してしまいます(※)

time.Time*time.Time は別の型なので。

こう言うときにわざわざ参照型の変数をフィールドに代入するためだけに toDatetimePtr みたいなメソッドを定義することがありますが、実装で使うならまだしもテストの時だけに使うためにこういった参照型変換関数を用意するのは微妙です。

Example

type User struct {
    BirthDay *time.Time
}

みたいな User エンティティを使ったテストを書くときに

func TestUserXXXX(t *testing.T) {
    tests := [] struct {
        name string
        src  User
    }{
        {name: "xxxxx....",  src: User{ BirthDay: toDatetimePtr(time.Now())} },
    }
}

func toDatetimePtr(t time.Time) *time.Time {
    return &t
}

わざわざ time.Time の参照型である Birthday のためだけに toDatetimePtr を定義するのはちょっと微妙なので以下のようにしたらいいのではないかなと思いました。

func TestUserXXXX(t *testing.T) {
    tests := [] struct {
        name string
        src     User
    }{
        {
            name: "xxxxx....",  
            src: User{ 
                BirthDay: func(t time.Time) *time.Time {
                    return &t
                }(time.Now()),
            }, 
        },
    }
}

まとめ

これを書いてる最中にじゃあ参照型の型を新しく定義すれば?と思いましたが、いずれにしても利用用途が限定的なケースにおいてあんまり書きたいコードではないなと思いました。

Go では通常の実装においても func をそのまま使うと有用なケースが多いですね。

追伸

func の方が func を呼び出すコストがかかるので使いどころは選ばないといけません。
教えてもらった以下の Compier Explore によると確かに func を持っている時の方がコストが高いです。

https://go.godbolt.org/z/P9rWPP

同一文字列を繰り返す strings.Repeat

Overview

テストケースで使えそうな strings の機能を教えてもらったので書いておきます。

Strings.Repeat

https://golang.org/pkg/strings/#Repeat はある文字列を指定した回数分繰り返して文字列を生成するメソッドです。

こんな感じで繰り返し出力してくれます。

package main

import (
    "fmt"
    "strings"
)

func main() {
    s := strings.Repeat("s", 10)
    fmt.Println(s)
}
// Output: ssssssssss

ref: https://play.golang.org/p/Dgsy3YjHRUi

テスト書くのに使えそう?

テストケースで文字数の境界値のテストをしたいケースってあると思いますよね。ある関数、メソッドのテストをしたいケースにおいて以下のように使うことができます。

func TestMethodA() (t *testing.T) {
    tests := []struct{}{
        name  string
        input string
        want  bool
    }{
        {name: "case_a", input: strings.Repeat("s", 10), true},
    }

    for _, tt := range tests {
        tt := tt
        t.Run(tt.name, func(t *testing.T){
            // MethodA のテスト
        })
    }
}

別に境界値分のStringを用意してもいいですが、意味のある文字列(JSONとかHTMLとかね)以外の単純な文字列であれば strings.Repeat を使って長い文字列を生成するといいのではないかなと思いました。

DBで絵文字を保存するときは文字列長に注意する

Overview

https://ema-hiro.hatenablog.com/entry/2021/01/19/141854 にて文字数をカウントするのは難しい話を書きましたが、追加の余談で、DBに絵文字付きのテキストを保存する時には、文字列長に注意しないといけないことについても記載します。

👩‍👩‍👧‍👧 がどんな構成になってるか?

👩‍👩‍👧‍👧 はこれ単体で25byteあります。

25 byte = 4byte x 4 + 3byte x 3

https://emojipedia.org/family-woman-woman-girl-girl/ にこの絵文字の仕様について記載してあるけれども、👩‍👩‍👧‍👧 は 👩 x2 と 👧 x2 と 3byte の繋ぎ文字3つで構成されてることがわかります。

rune で見ると 7文字であることがわかります。

ref: https://play.golang.org/p/XuJYU8q1gWm

VARCAHR(100) のカラムに 👩‍👩‍👧‍👧 を15文字入れたらどうなるか?

答えだけ言うと 15 文字全て 👩‍👩‍👧‍👧 は表示されません。14文字の 👩‍👩‍👧‍👧 と 1文字の 👧 が表示されます。

理由は上記にも記載した 👩‍👩‍👧‍👧 が複数の絵文字から構成されているからです。

VARCHAR (100) の場合 MySQL の仕様上は空の場合も1文字として計算されるので、文字列としては99文字まで入ります。そのため7文字使ってしまう 👩‍👩‍👧‍👧 が14文字と1文字計算の 👧 までしか保存されません。

See Also

Go で文字数をカウントする

Overview

Go 言語で文字数をカウントする方法について記載します。

そもそも文字数を数えるとは?

LINE Engineering のこのブログが詳しくてわかりやすいです。

engineering.linecorp.com

ある文字列の文字数を計算するときに、コンピューター上で「何文字」として扱われるかは、文字の定義によって異なります。

Go における文字数のカウント方法

utf8.RuneCountInString を使う

マルチバイトの文字(漢字など)を使ったテキストの総文字数を Go でシンプルに計算するときは https://golang.org/pkg/unicode/utf8/#RuneCountInString を使います。

例えば以下のように使用します。

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "こんにちは"
    ss := utf8.RuneCountInString(s)
    fmt.Println(ss)
}

この実装の出力結果は 5 です。

しかしこの文字数の数え方には 絵文字が入った文字列では正確に文字数をカウントできない という漏れがあります。例えば以下のようなケースでは✌️ は Rune Slice の長さ 2 のマルチバイト文字列であることがわかります。

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "✌️"
    ss := utf8.RuneCountInString(s)
    fmt.Println(ss)
}

Grapheme Cluster を使う

ユーザーが認識する文字。文字体系で表現できる文字の最小単位。1 Graphemeは、N code pointで構成されています。

ref: https://engineering.linecorp.com/ja/blog/the-7-ways-of-counting-characters/

Go で Grapheme を使う

OSS として出してくれてるライブラリがあります。

github.com

実装方法は上記の Readme に書いてる通りです。

このライブラリ (というか Grapheme) は Rune Slice が 2 以上あるマルチバイと文字列も「1文字としてカウント」してくれます。

具体的に文字数を数えるときは以下のように実装します。

func main() {
    var sum int
    gr := uniseg.NewGraphemes("✌️!")
    for gr.Next() {
        sum++
    }
    fmt.Printf("%d", sum)
}

この出力は2です。

✌️ + ! の文字列しかないので 2 になります。 utf8. RuneCountInString を使用した場合、上記の文字列は 3 と表示されます。つまりマルチバイトの文字まで数えてしまいます

余談: 同じ絵文字でも文字数が違う場合がある

👉 という絵文字にも以下のような種類が存在します。

f:id:ema_hiro:20210119141227p:plain

この別の肌の色をどう表現してるのかを調べました。

package main

import (
    "fmt"
 
    "github.com/rivo/uniseg"
)

func main() {
    s := "👉"
    gr := uniseg.NewGraphemes(s)
    for gr.Next() {
        fmt.Printf("%x", gr.Runes())
    }

}
// Output: [1f449]

別の肌の色では以下でした。

package main

import (
    "fmt"
 
    "github.com/rivo/uniseg"
)

func main() {
    s := "👉🏻"
    gr := uniseg.NewGraphemes(s)
    for gr.Next() {
        fmt.Printf("%x", gr.Runes())
    }
}
// Output: [1f449 1f3fb]

👉 に色の文字コードが追加された出力されました。なお utf8.RuneCountInString を使用した場合、👉 は1文字ですが、👉🏻 は2文字で計算されます。

同じ絵文字でも文字数が違うことがわかりました。

ちなみに Code Point を調べるとこのことは一発でわかります。

emojipedia.org

👉 U+1F449
🏻 U+1F3FB

と別のデータとして扱われてますね。

まとめ

目で見てる絵文字と、コンピュータ上でどう扱われるかには差があるので「文字数をカウントする」という実装はかなり奥が深い(というかややこしい)ことがわかりました。

Grapheme cluster は覚えておこうと思います。