yigarashiのブログ

学んだことや考えていることを書きます

なぜ俺たちはタスクを分割するのか

開発のプラクティスとして大きいタスクを小さく分割するというのがある。しかしよく考えると何が嬉しいかを的確に説明するのが難しいプラクティスだと思う。現在進行形でチームに広めようとしてうまく言葉が出なくて困っている。なぜ俺たちはタスクを分割するのか、思いつくポイントを整理して思考をクリアにしようと思う。

ポイント1: 見積もりのため

大きなタスクについて開発にかかる時間を見積もる必要があるのなら、タスク分割の必要性は明らかだと思う。大きいタスクを大きいまま見積もるのは難しい。外れやすくなるし、3倍とか4倍とか外れる度合いも大きくなりうる。そういう場合に、予測をつけやすい手頃なサイズに分割してそれぞれを見積もるというのは圧倒的に正しい。ぜひやるべきだ。見積もりされたタスク一覧があると、進捗を可視化することもできる。

ポイント2: 実装方針を間違いすぎないため

仕様が大きく実装方針が明らかでない時も、タスク分割は有効だと思う。複雑なものを複雑なまま考えるのは難しい。大量にコードを書いていって、最後の最後に破綻していると分かるということが起こりうる。これは大変ダメージが大きい。できることなら小さく失敗したい。まず全体を捉えて、そのあと小さく分割して、それぞれを簡単な問題にして解いていく方が賢明だと思う。もちろん、その分割に時間がかかりすぎても困るが、基本的にはそのタスクの複雑さにいつ対処するかというだけの違いで、かかる時間は大して変わらないと信じたい。その分割の過程でコードを書いてはならんという法もないので、3割くらいの力で適当なコードを書いてみても良い。

ポイント3: レビュワーの負担を減らすため

大きいpull requestをレビューするのは大変だ。大きいタスクを自分で実装するよりも大変に感じることすらあるかもしれない。タスク分割をしないと、大きいタスクに対応して大きいpull requestを作ってしまいがちだと思う。タスク分割という形でマージ可能なサブセットを事前に抽出しておくと、それをガイドラインにしてレビュワーの負担を軽減できる可能性がある。コミットを綺麗にするので大丈夫という声が聞こえてきそうだが、コミットを綺麗にする重要性が理解されているなら、それをpull requestやタスクという単位に応用するだけのことだ。タスク全体での整合性を見落としやすくなるので、その点は事前の設計レビューなどで補う必要がある。

ポイント4: 手分けをするため

ホールケーキをみんなでつつくようなやり方はソフトウェア開発では難しい。大きなタスクを手分けして進めるためには分割するしかない。タスク分割を上手に行うと、個人の負担を減らし、フィーチャーがリリースされるまでの時間を短縮できる可能性がある。ただ、文脈を共有したり分担できるほど設計を練るのはコストがかかるので、あくまでバランスという程度のポイントではある。

ポイント5: 自分のモチベーションのため

大きな仕事をモチベーションを維持してやり遂げるのは大変だ。僕なんかは、3日もpull requestを出せないでいると、何もできていない気がしてどんどんやる気が下がる。1日1個おわるくらいのタスクに分割しておいて、毎日確実に終わっていくというのは精神衛生上かなり良いと思う。一度書き始めたコードをずっと書いてしまうとこういう区切りをつけるのが難しくなるので、どこかでタスク分割フェーズと実装フェーズを明確に分けるのは良いプラクティスじゃないかと思う。最終的に生産性を高める可能性はある。


今のところ思いつくポイントはこのくらいだ。ひとくちにタスク分割といっても、そのメリットが多様であるということが一覧されて、思考が少しクリアになった。それぞれでメリットを享受する人が異なるというのもわかった。また、一覧してみるとMUSTでやるべきと思うのはポイント1と場合によってはポイント4くらいで、他は加点項目という印象がある。このあたりがスパッと説明できなかった要因かもしれない。

実際にやっていくことを考えると、タスク分割の丁寧さにも段階があって、誰が見ても着手できる説明をつけてチケットを切る松プランから、箇条書きでチェックリストを作る梅プランまである。どんなメリットを享受したいかに合わせてやり方を検討すると良い。

開発の導入準備おすすめ仕草3つ

最近、自分が先行して開発をして、地盤が整ったところで他のメンバーにも開発タスクをやってもらうという場面があった。特に、チームでは経験のないツールを使ったデータ基盤に関するタスクであったので、手厚く導入をすることが重要であった。その時の準備が好評で開発体制の素早い拡大につながったので、おすすめ仕草として3つまとめておく。改めて整理したら自分も十分には書けていない項目があったのであとで直しておこうと思う。

その1: いわゆるREADME

まずは最も当たり障りのないところから。以下のような内容をざっくりまとめる。

  • 何のために作っているか
  • どんなミドルウェアを使っているか
  • 何をしているか
  • 主要なディレクトリの説明
  • 環境構築
  • 実際に動かせるサンプル

特に「実際に動かせるサンプル」は大事だと思う。多くのOSSが当たり前に備えている「Getting Started」の項目に相当するもので、そのリポジトリの動作イメージが一気に具体的になるし、コードリーディングの起点にもなるので準備できると良いと思う。Webアプリケーションなんかはとりあえずローカルで起動することになるので忘れがちだが、試しに変更を加えてみるチュートリアルを置いておくといったことで同じ効果が期待できるだろう。

その2: サンドボックス

好きに変更して動作確認をできる環境を用意するのは一般に良いことだと思う。特に、クラウドサービスを使いまくったバッチ処理のような場面ではサンドボックスを用意するのも一手間かかるので、本番で試行錯誤のイテレーションを回してしまいそうになるが、やはり手元で好きにできるに越したことはない。その1で実際に動かせるサンプルを作るために、そうした環境が自動的に必要になる可能性も高い。ローカルでは本番と違う設定やコマンドが必要になるケースもあるのでそういったものも準備しておく。

今回のケースでは、異常なデータを再現しやすいようにdocker-composeでMySQLも立てられるようにしておいて、さらにサンドボックスを充実させたりしている。

その3: 作業ログ

典型的なタスクについて、一連の作業手順をメモしまくったログを残しておくと良い。例えば自分の場合は以下のような内容をひたすら書いておいたところ、それを後追いするだけで開発を進めてもらえた。

  • 認証のためのトークンをxxから取得
  • ooのホストに向けて以下のSQLを叩いて件数を確認
  • 設定のooをxxに変えておいて動作確認する
  • 件数が多いので設定の--をxxにしておく

特に、事前調査の方法や実装方針の選択は暗黙知になりやすいので、そういった内容が現れるように意識して書くと良い。自分の体験だと、どのDBホストでどのくらい好き勝手にクエリを投げて良いかといった感覚を醸成するのが難しかったので、作業ログでSELECT COUNT(*) FROM xxxとかしている様子が書いてあるのは、単に何をしたら良いかわかる以上の効果があると思う(主に新人向け)。


以上!良いドキュメントを書くとスムーズに開発が広がってみんなが幸せになるので、いろんなテクを駆使して書いていきましょう。

KPTのKがいまいち膨らまないチームに贈るパワフルな質問

ふりかえりでKPTのようなポジティブ/ネガティブな話題を出すような手法を使っていると、悪いところの掘り下げはサクサクできる一方で、良いところをうまく膨らませるのが意外と難しいように思います。書いた本人に話してもらって、ファシリテーターが「いいですね」とコメントして終わりとか、「コメントないですか」と聞いて誰も話さなくて終わりとか、そういった場面を見たことがある人は多いんじゃないかと思います。悪いところを解決するのは慣れていても、良いところを伸ばすための道具箱が空っぽという状態ですね。

そんなチームの最初の道具として次の質問を贈ります。

それがうまくいった要因は何かありますか?

これです。なんとかのひとつ覚えで良いので、ちょっとでも話が広がらないなと感じたら、まずはこれを聞いてみると良いです。この質問が優れているのは、出来事や本人の経験から単刀直入にプラクティスを抽出できることです。何か聞き出せたらしめたもので、チームにKeepを蓄積するコーナーがあればそこに書けば良いし、さらに強力に展開するなら、そのプラクティスを定着させるためのアクションを議論するのも良いでしょう。良い出来事を強化するというポジティブな体験は、チームにも良い影響を与えると思います。

この問いかけ自体はSCRUM MASTER THE BOOKの受け売りで、他にも1on1やコーチングの本をちょっとめくればどこにでも書いてあるだろうと思います。ただ、ジュニアメンバーはそういう領域の知識はあまり必要とされないでしょうし、シニアメンバーも1on1の道具として持っていても、チームに向けて使う発想を持っている人は意外と少ないのではないかと感じています。ふりかえりのファシリテーションはチームを相手にしたコーチングです。少しずつ道具を揃えてふりかえりを充実させていきましょう。

Goのcontextについて調査したり実装を読んだりしたまとめ

最近Goを勉強している。仕事でたまに使うというのもあるし、用途が広く色々とつぶしがききそうという期待もある。ちゃんと勉強してみると思ったより手触りが良いので、気になったライブラリについて調査しながら少しずつGo言語力を高めようと思う。ということで今回は標準ライブラリのcontext。

概要

なにはともあれcontext - The Go Programming Languageを読む。

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

なんらか処理をリクエストするときにContextオブジェクトを渡すと、呼び出し側でデッドラインを設定したり任意のタイミングでキャンセルできるというのがざっくりとした理解。ミニマムな例としては以下のようなものが書ける。これを実行すると、5秒スリープしたのちcontext canceledというメッセージが表示される。

package main

import (
    "context"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    ch := make(chan string)
    go callee(ctx, ch)
    cancel()
    println(<-ch)
}

func callee(ctx context.Context, ch chan string) {
    <-ctx.Done()
    time.Sleep(time.Second * 5)
    ch <- ctx.Err().Error()
}

上から簡単に解説していく。Contextオブジェクトはキャンセルを伝播できるようにツリー構造を形成しており、context.Background()はそのrootを取得するものである。

context.WithCancelは渡されたContextをコピーしてchildrenに加えたのちキャンセル関数とセットで返してくれる。cancelを呼ぶとctx.Done()でアクセスできるchannelにシグナルが送られる。callee側はそれを以って実行のキャンセルと判断しキャンセル処理を始めることができる。ここでは5秒スリープしたのち、ctx.Err()で取得できるContextのキャンセル理由をメインのスレッドに送信している。タイマーを設定したい場合はcontext.WithCancelに代わってcontext.WithDeadlinecontext.WithTimeoutを使うと良い。

またcontext.WithValueによってkey-valueペアを保持することもできる。これはrequest-scopedな値に使うと良いとされている。おそらく依存性の注入のようなイメージでrepositoryオブジェクトを渡すといった用途に使うのはあまり良いプラクティスではなさそうなので注意が必要。

Go Concurrency Patterns: Context - The Go Blogも入門としては良い感じがする。

実践的な利用例

時間によって処理をキャンセルする例として、Webサーバーのgracefulなシャットダウンが挙げられる。SIGTERMを受け取ったら、現在のリクエスト処理が全て完了するか、一定時間が経過したらサーバーをシャットダウンするものである。

標準ライブラリのnet/httpが提供しているHTTPサーバーは、まさにContextオブジェクトを受け取るShutdownメソッドを提供している。

https://golang.org/pkg/net/http/#Server.Shutdown

func (srv *Server) Shutdown(ctx context.Context) error

軽く実装を覗いてみると、基本的にexponentialに待ち時間を増やしながら全てのリスナーが閉じるのを待機し、それとは別にctx.Done()のchennelを待ち受けて強制的に終了することもできるようになっている。

go/server.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

    timer := time.NewTimer(nextPollInterval())
    defer timer.Stop()
    for {
        if srv.closeIdleConns() && srv.numListeners() == 0 {
            return lnerr
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-timer.C:
            timer.Reset(nextPollInterval())
        }
    }

このサーバーを利用する側としてはgorilla/muxのGraceful Shutdownの例が参考になる。OSのシグナルを受け取るチャンネルで処理をブロックしておき、シグナルが来たらcontext.WithTimeoutでContextを生成して先程のShutdown関数に渡している。

contextの実装を軽く読む

せっかくなのでライブラリ自体の実装も軽く読んでみる。

まず以下はcontext.WithCancelが返すContextオブジェクトの実体を表すstructである。 go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

type cancelCtx struct {
    Context

    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}

公開されているContextのメソッドを持っているのは当然として、Contextを複数のgoroutineでシェアしても問題ないようにsync.Mutexで保護している。donecontext.Doneが返すチャンネルで、こちらもatomic.Valueで保護されている。並行処理に詳しくなく、doneに二重の保護が必要な理由はよく分からなかった。childrenはContextのツリー構造を管理するためのものである。errはキャンセル理由を格納するフィールドである。

キャンセル処理は以下のようになっている。context.WithCancelなどが返すキャンセル関数は以下の関数を簡単にラップしたものになっている。

go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    d, _ := c.done.Load().(chan struct{})
    // 解説: 閉じたチャンネルを格納するか既存の開いたチャンネルをcloseするかしてreceiverのブロックを解除する
    if d == nil {
        c.done.Store(closedchan)
    } else {
        close(d)
    }
    // 解説: 子のContextにcancelを伝播させる
    for child := range c.children {
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

removeFromParentは親のContextから自分自身を取り除くかどうかを表すフラグで、Contextの利用者がキャンセル関数を呼ぶ場合やデッドラインが来た場合はそのContextだけがキャンセルされるのでtrue、親からキャンセルが伝播してきたり親が管理する場合はfalseとするようだ。

他にもcontext.WithDeadlineなんかを見ると、time.Afterfuncを使ってcancel関数を仕込んでいることがわかる。

go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }

全体的に抽象度の高いAPIを組み合わせて簡潔に実装されており、初学者でも読みやすいライブラリだったと思う。structやErrorの設計、並行処理についても典型的なことをやっていそうに見える。

その他の話題

Contexts and structs - The Go Blog

Contextはstructの中に含めずそのままの形で関数の第一引数に置くべきということが書かれている。本文の例を見るに、特にメソッドのレシーバーに含めて種類の異なるリクエストで単一のContextを共有するのがマズいということらしい。WithValueの解説を見ても、Contextオブジェクトのスコープを単一のリクエストに制限するというのは重要なプラクティスに見える。net/httpパッケージで後方互換性のためにRequestにContextを含めたという話も書いてあるが、確かにRequestオブジェクトに含める分にはContextのスコープは単一のリクエストで済むケースが多そうなので、妥当なように見えた。

BigQueryっぽい小技を組み合わせてGA4のデータのページタイトルを最新の値に正規化する

Google Analytics 4(通称GA4)は最近利用できるようになったGoogle Analyticsの新しいプロパティで、全てのデータを簡単にBigQueryにエクスポートできるのが大きな特徴です。私もGA4を使ってこのブログのアクセス解析を行っており、BigQueryでナイスなビューを作成してデータポータルで可視化しています。その中でも「ページごとの閲覧数」が意外と曲者だったので、それを紹介します。

「ページごとの閲覧数」と言ったとき、多くの人が期待するのは、各行にページのタイトルと閲覧数が並んでいるような表だと思います。しかし次のような課題があります。

  • ページのタイトルが不変とは限らない
    • 例えば、はてなブログはブログ名が各記事のタイトルに入るので、ブログ名を変えると全ての記事のタイトルが変わります。他にも「(追記あり)」と付けてみるだとか、タイトルが変わる要因は無数に考えられます
    • 実際GA4レポート画面の「エンゲージメント > ページとスクリーン」はページタイトルで集計されているので、ページタイトルが変わると表示回数などの集計も分かれます
  • ページのURLだけだと表から情報を読み取りづらい
    • タイトルが変わるならURLで集計したら良いと考えるわけですが、素朴にページのURLと閲覧数で表を作ってみると想像以上に見づらい表になります。タイトルだと一瞬で情報を掴めたのに、ひとたびURLに変えると各行をグッと睨んでどんなページだったか 考えないと何も読み取れないのです。不思議ですね

こうした課題を解決するために、以下のようにして正規化したタイトルを取得した上で集計するのが望ましいと考えました。

  • 各URLの最新のタイトルを取得する - (1)
  • ページのURLごとに閲覧数を集計する - (2)
  • (1)と(2)をJOINして最新のタイトルと閲覧数の組にする

ここからは実際にクエリを見ながら解説していきます。

ステップ1: 必要な期間から必要なレコードだけ抜き出す

今回は過去28日のpage_viewイベントがあれば十分なので以下のようにします。

WITH records_of_last_28_days AS (
  SELECT
    event_date,
    (SELECT value.string_value FROM UNNEST(event_params) x WHERE x.key = 'page_location') AS page_location,
    (SELECT value.string_value FROM UNNEST(event_params) x WHERE x.key = 'page_title') AS page_title,
    (SELECT value.string_value FROM UNNEST(event_params) x WHERE x.key = 'page_referrer') AS page_referrer
  FROM
    `xxx.yyy.events_*`
  WHERE
    _TABLE_SUFFIX BETWEEN FORMAT_DATE("%Y%m%d", DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 29 DAY))
    AND FORMAT_DATE("%Y%m%d", DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 1 DAY))
    AND event_name = "page_view"
),

BigQueryっぽい見どころは以下の2つです。

取り組んでいる時の様子です。

ステップ2: 各ページURLの最新のタイトルを取得する

以下のようにします。

location_to_title AS (
  SELECT page_location, page_title FROM (
    SELECT
      *,
      ROW_NUMBER() OVER (PARTITION BY page_location ORDER BY event_date DESC) AS rn
    FROM records_of_last_28_days 
  ) WHERE rn = 1
),

まず分析関数のPARTITION BYでpage_location(つまりページのURL)ごとにレコードを分割しそれを新しい順に並べます。ROW_NUMBERは分割したパーティションごとに行数を返す関数です。ここまでやると、WHERE rn = 1でpage_locationごとに最新のpage_titleが入っている行を取得できます。

この手法は数百GBのデータをMySQLからBigQueryへ同期する | メルカリエンジニアリングから着想を得ました。分析関数については公式ドキュメントのこちらのページが詳しいです。

ステップ3: page_locationごとにイベント数を集計してpage_titleとJOINする

以下のようにします。page_locationに加えてpage_referrerでもGROUP BYしてカウントしておくと、データポータルでいい感じにドリルダウンできるのでオススメです。

pv_by_location_and_referrer AS (
  SELECT page_location, page_referrer, COUNT(*) AS pv FROM records_of_last_28_days GROUP BY page_location, page_referrer
) SELECT page_title, page_referrer, pv FROM pv_by_location_and_referrer JOIN location_to_title USING (page_location);

データポータルで便利なデータの形というのはなかなか感覚が掴めておらず難しく感じます。


以上です。自分のブログにGA4のタグを入れて、BigQueryにデータを流しておくだけでおもちゃがひとつ増えるのでオススメです。