コネヒト開発者ブログ

コネヒト開発者ブログ

【永久保存版!】プロジェクトリーダー必見!!チームふりかえりを最高に楽しいものにするたった一つの方法【リモートワーク対応】【2022最新版】

こんにちは ohayoukenchan です! 4月と言えば新生活。コネヒト株式会社も4月から、経営体制を一新し新たなスタートを切りました。 今期も心機一転して頑張っていきたいと思います。

この記事では先月末に開催した下期(6ヶ月)のチームふりかえりで行ってとても良かったなと思ったことについてお伝えできればと思います。

中長期(数ヶ月間隔)のふりかえり会の意義

スプリントでのふりかえりは、スプリントごとにレトロスペクティブの時間を設けています。 KPT法に似たような方法ですが、例えば下図のような感じでチームで起こったできごとに「ありがとう」や「happy-bad」と書かれた領域に付箋を貼って、特に関心の高いものに対して次のスプリントへのtryを決めていきます。

レトロスペクティブ
スプリントごとのふりかえり

また、弊社の別のチームでも、Win Sessionで元気に目標を達成するチームづくりの記事にあるように、チームが元気な状態で目標を達成できるように、来週も頑張るぞと思えるような取り組みを行っています。

では、中長期(数ヶ月間隔)のふりかえりはどのように行えばよいでしょう? 今回開催した下期ふりかえりも、チームが元気な状態で来期に向けて頑張る気持ちを醸成したいと思い企画しました。

ふりかえり会の内容

ふりかえり会を企画した時に注目したキーワードは「自己肯定感」です。
ふりかえりが終わった後に、自己肯定感が満ち溢れているメンバーの顔を想像しながらアジェンダをつくりました。

自己肯定感が高ければ、自分に自信を持つことができ、何事にも積極的に取り組んでいけると思いました。逆に自己肯定感が低いまま次のスタートを切ることになると自信が持てず、仕事を置きに行くことになりがちです。仕事を置きにいくようになってしまうと目標がゴールになり、それ以上の成果は望めません。

それでは、自己肯定感をあげるにはどうすれば良いでしょう? 一番確実でほとんどのメンバーが喜ぶのは「評価される = 褒められること」だと思います。

チームメンバーを一人残らず褒めちぎるにはどうすればよいか考えた方法がこちら。(下記画像)

メンバ一人ひとりにおてがみを書いてもらう

そうです。メンバーひとりずつ他のメンバーに対してお手紙を書いてもらうだけです。 半年間通して一番近くで仕事をしてきたメンバーからみた印象を書いてもらいます。それを当日読むだけです。

当人が努力したことでも、誰からも評価されなければ無価値、無意味などの消極的な思考が脳内を支配してしまいがちですが、チームのメンバーは見ていた、努力していたことを知ってるはずです。 AさんがBさんをねぎらうだけでBさんは救われた気持ちになって、BさんもまたCさんを救うのです。最高です。

これが私がチームメンバーからもらったお手紙です。

お手紙

私はこの半期、さまざまな駄作なアイデアや、良かったと思ってもらえるアイデアを出してきましたが、ここでアイデアマンという評価をもらえたのは嬉しかった。アイデアも不作が続くと自信がなくなってくるので「もっと出していこう!」「いいんだ。アイデア出して!と思える最高な💌でした。また、子育て中ということもあり、チームの行事に参加できなかったりしていた後ろめたい気持ちも完全に取り払ってくれました。ありがとう!

似たようなものに360°フィードバックがあります。360°フィードバックは評価者からみて被評価者の評価を決定するためのシステムとしては良いですが、どうしてもフィードバックする側は評価されるべき功績をフィードバックするので、今回の目的である自己肯定感をあげることには繋がりにくいかなと思います。

ふりかえり会で準備したこと

準備するのはお手紙にするテンプレートを一枚用意するだけです。

他の人からテンプレートを閲覧できないように注意しつつ、後は趣旨をDMなどで伝えます。

これをメンバー分繰り返して、書いてもらっていない人がいなければ完了です😊

あるといいもの

今回は、チーム個人の自己肯定感をあげることの他に「チームで頑張って良かった。また頑張ろう」という気持ちも醸成したかったのでチーム外からのフィードバックも集めることにしました。

  • メンターだった先輩からの💌
  • CSチームからの💌
  • CTOからのはげましの💌
  • 社を去ることになった前代表からの💌

基本的にはチーム外からみたチームの印象を書いてくださったのですが、今回の大きな気づきとして、他の人にお願いする方が自分が想定していたものより素晴らしいものが出来たということでした。

例えば、CSチームのメンバーとの取り組みからお手紙をもらおうと自分は青写真を描いていたのですが、CSチームからもらったお手紙には自分たちのチームがこれまで改善したことにたいする「ユーザーからのフィードバック」を添えてくれていました。このことは全く想定していなかったし、結果想定した以上にチーム内からの反響も大きかったです。

想定の範囲内という言葉がありますが、想定の範囲内で物事を動かしてはもったいないです。自分ですべてやるより周囲の人を巻き込んでいくと良いと思います。

定量は測れませんが、定性的には良い会だったであろうことを感じていただけると思います😋

最後にひとこと

自己肯定感をあげる方法は他にもあると思うので、必ずしもこの通りやる必要はないと思いますし、所属するチームの状況によって最適な手段を選んでいくのが大事かなと思います。 大事なのはチームメンバーを一人残さず褒め讃えて、自己肯定感が高い状態で次の期へ突入することです。 あと、内容は作り込み過ぎずブレスト段階でどんどん協力者に移譲していきましょう。

今回の記事はプロダクトゴールのふりかえりについてでした。弊社のプロダクトゴールの運用についてはこちらの記事が参考になりますので、是非のぞいてみてください

tech.connehito.com

4月から新しい期が始まり、ロケットスタートで階段を駆け上がっていくイメージの弊チームですが、まだまだやりたいことがたくさんあり、全然手が回っていません!

バックオフィス、UIデザイナー、エンジニア、PMMなど多業種でご応募お待ちしておりますので、 ohayoukenchanにDMでお声がけください。

下記募集一覧からご応募もできます。

hrmos.co

よいチームを作っていこうず!

コネヒトの文化が生み出すスキルアップを支える社内LTイベント

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

前回のエントリーで、髪の毛のアップデート予定について触れましたが、重い腰を上げて予定を決めました。4/3を予定しています。

さて今回は、先日社内で実施したLTイベントの技術目標マルシェについて紹介します。

はじめに

まずは今回の社内イベントについて補足させてください。

シンプルにいうと、スキルアップ目標の工夫をシェアして、お互いに刺激を受けるイベントです。

マルシェ is 何?

社内で実施しているLTイベントのコネヒトマルシェのコンセプトでもあるみんなの「知りたい」「知ってる」をおすそ分け!をテーマに、技術目標でやったことをアウトプットできる場、そして、みんなが和気あいあいと交流出来る場を目指しています。

技術目標 is 何?

エンジニア組織に所属するメンバーが半期ごとに持つ、個人のスキル成長を促す技術的な目標です。直接会社に貢献するものでなくてもOK(全く関係ないものはNG)ですが、計画的にスキルを伸ばすことを念頭に置き、期初に成果指標を置いています。 今回のイベントは、技術目標に関連したアウトプットを行うことで、コミュニケーションが生まれて、仲間からのいい刺激を貰い、また自分も渡せる場として、期末に実施をしています。

イベントの内容

開発組織に所属しているほぼ全員(18名)がそれぞれ学んだことを発表しました。ランチタイムも含んで、合計5時間のイベントになりました。タイムテーブルは以下の通りです。

タイムテーブル

時間 内容
12:00 ~ 13:00 はじまりのお話 + LT × 4
13:00 ~ 14:00 ランチタイム
14:00 ~ 15:20 LT × 7 + 10分休憩
15:40 ~ 17:00 LT × 8 + 10分休憩
17:00前後 おわりのお話

一人あたり5~10分のLT枠を好きに使ってもらい、10分オーバーした場合は、インターセプトして終了する予定でしたが、進行していく中で、余白の時間が生まれてきたため、ゆっくりと進行することができました。発表一覧は以下の通りです。

発表一覧

タイトル キーワード
ポートフォリオを作ったぞい Vercel / React / Next.js / CSS Module / Every Layout
テストとかLTとか React / Jest / testing-library LT: FastAPI
家族ノートのフロントエンドを改善してるぞい React / Jest / CakePHP
Graph Embeddingを用いたタグのベクトル表現分析 python / データ分析 / node2vec
洋書で読んでまとめるぞい 洋書 / オブジェクト指向設計
AWSの認定資格 AWS / 認定資格 / クラウドプラクティショナー
技術目標で作ったサービス紹介 Next.js / TypeScript / Tailwind CSS / Jest / MSW(Mock Service Worker)
CakePHPerのためのLaravel教養講座 PHP / Laravel
Deno入門 Deno / Deno Deploy
問いかけのススメ マネジメント / コーチング
100年ぶりの Go Go / Android
くるるん検査器を作ったりくるるんを動かす iOS / ML
知ってるようで知らないサジェストの裏側の世界 Elasticsearch
FY21下期のアウトプット駆動で得た知見たちをおしゃべりする 書籍執筆 / Python
Goワカラナイ しくじり先生編 Go
フレームワークを写経した感想 PHP / フレームワーク
#phperkaigi2022のスライド作成RTA PHP / テストフィクスチャ
SwiftConcurrencyダイジェスト版 iOS / Swift Concurrency

それぞれがさまざまなジャンルを学んでいたため、バラエティに富んだ内容となりました。自分の業務領域を深めるメンバーもいれば、普段の領域から離れたことを学んでいるメンバーもいました。

工夫したこと

イベントを開催する上でに、主に3つの工夫を凝らしました。

  • 発表のハードルを下げる
  • 有志とイベントを作る
  • 次につながる仕掛けをする

発表のハードルをさげる

評価の場でないことを伝えたり、スライドに落とす以外のLT方法もウェルカムとしたり、進捗に不安がある場合に1on1の活用やもくもく会を活用することを事前にアナウンスしていました。

技術目標マルシェは「正式な評価の場」というわけではないのでどんな内容でも、それが原因で評価が下がることはありません。
あくまで前述した通り「アウトプットの場」、「相互コミュニケーションによる技術目標の推進」を目的としています!

- 発表フォーマットはなんでもOK!
    - スライドで発表、成果物のデモ、フリートーク、投稿したブログの紹介、工夫したことetc
- 毎月のテーマ & 振り返りを活用しましょう
    - まずは自分が納得できる状態を目指して欲しいです。
    - その上で誰かと壁打ちできると良いと思うので、気軽に相談していきましょう
- 技術目標もくもく会を活用しましょう
    - 下期も有志メンバーがもくもく会を実施してます。
    - 「技術目標をやる時間がありません」というお悩みもあると思うので、是非活用してみてください

和気あいあいとした雰囲気で刺激を与えあうためには、自分が納得することと、やりやすい方法で発表することを、大事にしてほしいと考えていました。結果的にはスライドを作るメンバーが多かったと思いますが、それぞれが工夫をこらしていたので、長時間のイベントでも集中力を切らさず、聞けた感覚がありました。

有志とイベントを作る

イベント当日を、よりよい時間にするためには、わたしだけのアイディアでは、不十分だと感じていました。開発組織のメンバーが揃うミーティングで、有志メンバーを募り、委員会を結成しました。

わたしが決めかねていると、色々なアイディアを出してくれたり、意見を伝えてくれたり、タスクを率先して拾ってくれたため、スムーズに進めることができました。

せっかくなので委員会のミーティングでの議事メモを公開しておきます。

- オンライン?オフライン?
    - コロナの見通しが立ってないので、オンライン。
- 技術目標マルシェはどういう位置付け?福利厚生というかみんなでワイワイ楽しむ時間と割り切っていいものなのか、いや20人をN時間拘束するから仕事としてちゃんとやってくれ、なのか
    - LT大会はビール飲みながら聞きたいですね〜
- 人数多くて、時間配分がむずい
    - 20人分だと長いし、時間をオーバーする人もいるかも
    - 直感的にはドラというか時間でちゃんと切るのが必要だと思う、iOSDCのLTみたいな感じ
        - まさにiOSDCをイメージしてた
    - 画面共有奪っちゃうとか(いらすとや表示するなど
    - お昼を挟むタイムラインにするとか?
        - 朝早く働いている人もいるので、時間ずらしちゃった方が良い?
    - ボックスMTGその日無くす(ずらす)とかもありかも
        - 話さなきゃいけないことはランチの前後に話してもらうとか
            - 組織編成の話とかが出てきた場合、心が休憩できるか?
- フィードバックはどう送ろうかな?
    - コール&レスポンスを含めると、Zoomのチャットで全部やった方が、盛り上がり感はあるかも
    - がやはZoom、フィードバックはシートだと移動が面倒。
        - まっさらなところに付箋を書くと心理的に書きづらい
    - 運営が後から、notionなどにフィードバック一覧を作るとか。
    - zoomのチャットだと流れるのが懸念だったが、後からまとめるのであれば良いかも
    - その方法でいくならば区切りをちゃんとしたほうがいいと思うので、運営からチャット欄に「◯◯さんLT開始、終了」みたいなのを書くとかかな
    - Slackでいいかも?
    - Ask the speaker的なのあるといいですけどね
        - 感覚的にはこの規模なら全員が全員の発表を聞いたほうがいいと思っているので、時間の制限を考えると、この日は聞くことに専念して、後日別の場(Web Talkなど)で話題に出すのはどうだろう?

次につながる仕掛けをする

多くのエンジニアにとってスキルをアップデートしていくことは、継続的に行うことが大事だと思います。今回のイベントの熱量を次に生かすために、定期的な社内サブイベントで参照しやすいように発表一覧にタグをつけてみました。

画像の一覧はWebエンジニアが、集まるイベント用のタグです。わたしはAndroidエンジニアなので、普段は参加していないですが、次回のイベントで改めて話題に出して、相互に感想を話すことを提案する予定です。

以上のように、3つの工夫を紹介しましたが、どの工夫にも根幹には、アウトプットできる場を用意し、みんなが和気あいあいと交流出来る状態を作り出すことを意識していました。

その後....

これはわたしが意図したことでは、全くないですが、発表したことを社内にシェアして、次のアクションにつなげているメンバーがいました。

おそらく、今回のイベントがなくても同じ行動をとってくれていたとは思いつつもイベントを実施したことで、新たな変化が生まれたようで、イベントを実施した甲斐があったと感じました。

感謝!

おわりに

さて、今回は、開発組織で実施したLTイベントについて紹介させていただきました。

イベント開催にあたり、できる限りの工夫は凝らしましたが、コネヒトに元から備わっている、アウトプットを真摯に受け止めたり、わきあいあいと技術を楽しむ文化がベースにあることで、想定していたよりも、いい時間を作れたと思っています。

内容について、カジュアル面談で補足できますので気軽にお声掛けください!

hrmos.co

SwiftUIでUIを宣言的にかけるようになりコードを書くのが楽しいぞい

こんにちは、ohayoukenchanです。

今回はSwiftUIについてです。 ママリではiOS13をサポートしているので、一部iOS13をサポートする内容が含まれます。

システムを長持ちさせる力

突然ですが、コネヒトのエンジニアリング組織はTech Visionというものを掲げており、概要としては「みんなでエンジニア組織強くしていこうず。」的なことが書いてあるんですが、そのなかの3つの技術力として「システムを長持ちさせる力」を重要な技術力として推進しています。

ママリiOSアプリでも最新技術の恩恵を受け続けられるよう日々コードのアップデートを行っております。

先日、弊社ではTech Vision推進の一環で、技術目標マルシェなるものが開催されました。詳細はこちらのポストをぜひ覗いてみてください!

tech.connehito.com

技術目標マルシェは社内イベントで、各自比較的自由に気になる技術を選んで発表するのですが、自分はCoreMLとVisionを使って画像分類したり、エッジ抽出した画像をSKTextureにしてSpriteKitで遊ぶという内容を発表しました。

f:id:ohayoukenchan:20220329100313p:plain

iOS版のママリも、直近まではStoryboardやUIViewを使った開発をしていました。

Storyboardを使った開発の場合、UIの基底となるStoryboardでは実装内容はわかりません。ここからUIKitを使って実装を付け加えていくのですがUIを組み立てるのに、UITableViewCellやUIViewを継承したファイルを増やしていくことになります。

なにが行われるかわからないstoryboardの例

f:id:ohayoukenchan:20220329100422p:plain

Storyboardを使った開発がレガシーとは言い切れませんが、昨今、reactを筆頭に、宣言的UIで書かれたコードの見通しの良さ、逆にUIKit(storyboard)でUIを組み立てていくコードの見通しの悪さを考えると、サポートバージョンを考慮しつつ、これから開発するシステムに関してはSwiftUIを使って開発していこうという結論になりました。

アーキテクチャについて

SwiftUIと相性の良いライブラリにTCA(The Composable Architecture)があります。状態の集中管理したり、scopeを使うことでwatchするstateの範囲を限定できることで、無駄に再描画が発生しなかったり、テストライブラリも用意されているので非常に魅力的でしたが、TCAの懸念としては、Viewも含めてTCAに強く依存してしまうので、TCAを使わなくなった場合に引きはがずのが大変そうであることが理由でTCAの採用は見送っています。

いままでのiOS開発のライブラリの流行り廃りを考慮すると他によいものがでてきて廃れる可能性もわりと高そうという議論もしました。

余談ですがFluxベースのライブラリの有名どころにreactのreduxがあると思うのですが、reactがhooksを導入したことでreduxなしで状態管理できるようなアプローチをとってきているので状態管理をどの場所で行っていくのか今後が気になっています。

https://github.com/pointfreeco/swift-composable-architecture

ママリiOS版のリアクティブプログラミング構成

ママリiOS版は、MVVMアーキテクチャで構成されており、APIやUIからのイベント送信などにRxSwiftやRxCocoaを使用しています。SwiftUIを導入するにあたり、RxSwiftを切り離し、代わりにCombineを導入することも検討しましたが、RxSwiftへの依存が強いことと、Rxコミュニティは活発でライブラリ更新も積極的に行われていることから、無理に引き離すような選択はしていません。

新規でUIを作成する場合、状況に応じてRxSwiftで流れてきた値をCombimeのPublisherにわたしたりしています。一つのファイルにCancellableDisposeBag両方書かなくてはいけないなど、コードの見通しが若干悪くなるのですが、これは移行期という捉え方が近いとおもっていて、継続的に運用を続けていくことを視野に考えると、その機能自体なくなるかもしれないし、該当機能に大幅なアップデートがかかるかもしれません。可能性を考慮するときりがないので今は移行期としてこのような仕組みになっています。

fileprivate let disposeBag = DisposeBag()

fileprivate var cancellables: [AnyCancellable] = []

Hosting Controllerの取り扱い

ママリでは既存のアーキテクチャとの兼ね合いもあり、画面遷移は UIViewControlerに任せることにしました。UIHostingControllerを継承したクラスの rootView に SwiftUIの View を渡すようにしています。 super.init(rootView:) するときにclass内のプロパティを初期化して渡してあげたいけどSuperクラスの初期化が終わってないのにサブクラスのプロパティにアクセスするなと怒られてしまいます。

コンパイルエラーの例

class DiagnosisInterestingTopicsViewController: UIHostingController<
    DiagnosisInterestingTopicSelectView
>
{

    private var cancellables: [AnyCancellable] = []

    var viewModel: DiagnosisInterestingTopicsViewModel()

    init(interstingTopics: InterestingTopicsResponse) {
        super
            .init(
                rootView: DiagnosisInterestingTopicSelectView(
                    viewModel: viewModel // 'self' used in property access 'viewModel' before 'super.init' call
                )
            )
    }

・・・

この場合、super.init(rootView:) の前にViewModelを作っておくとコンパイルエラーを回避することが出来ます。rootViewに指定したいViewの引数とClass内部で取り扱うviewModelを一致させるためにこうしてますが、見通しは悪いですね。

コンパイルが成功する例

class DiagnosisInterestingTopicsViewController: UIHostingController<
    DiagnosisInterestingTopicSelectView
>
{

    private var cancellables: [AnyCancellable] = []

    var viewModel: DiagnosisInterestingTopicsViewModel!

    init(interstingTopics: InterestingTopicsResponse) {
        let viewModel = DiagnosisInterestingTopicsViewModel(
            interstingTopics: interstingTopics
        )

        super
            .init(
                rootView: DiagnosisInterestingTopicSelectView(
                    viewModel: viewModel
                )
            )
        self.viewModel = viewModel
    }

・・・

HostingControllerで既存UIKitの画面を表示する

通信中画面はSVProgressHUDを使用しています。SwiftUIを使った画面でも既存のUIを使用したいので、SwiftUI側でSVProgressHUDを表示すると、SwiftUIの描画領域しかオーバーレイされず、NavigationBarなどがオーバーレイの上に表示されてしまいました。そのため、SVProgressHUDUIHostingController から呼ぶようにしました。

ママリiOS版はiOS13をサポートしているため、iOS13で検証したところ通信が発生してもオーバーレイが表示されず、検証したところviewDidLoadで処理してもviewModel.$progressState に値が流れず、viewDidAppearで呼ぶことで回避できました。原因は分かってないです。

class DiagnosisRegionSelectViewController: UIHostingController<DiagnosisRegionSelectContainerView>,
    DiagnosisPageable
{

    ... 初期化処理など省略

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        // iOS13だとviewDidLoadにおくと呼ばれないのでviewDidAppearで処理
        bindUI()
    }

    func bindUI() {
        viewModel.$progressState
            .receive(on: DispatchQueue.main)
            .sink { state in
                self.showProgressView(state)
            }
            .store(in: &cancellables)
    }

    func showProgressView(_ state: ProgressState) {
        switch state {
        case .asleep:
            SVProgressHUD.dismiss()
        case .connecting:
            SVProgressHUD.show(withStatus: "通信中", maskType: .black)
        }
    }

SwiftUIで宣言的にかける良さ

SwiftUI導入のメリットである宣言的UIを実現させたいので、複雑なロジックは持たず、UIの組み立てに集中させています。こちらはSwiftUIで書いた機能ですが1画面を構成するのに50行くらいのSwiftUIファイルを書くだけだったので大変見通しもよく(storyboardもcellもいらないなんて!)

感動しました

f:id:ohayoukenchan:20220329104948p:plain

struct DiagnosisRegionSelectSearchView: View {

    @ObservedObject var viewModel: DiagnosisRegionSelectViewModel

    private let maxCharacterLength = 7

    var body: some View {
        VStack(spacing: 0) {
            SearchBarRepresentable(
                text: $viewModel.zipCode,
                maxCharacterLength: maxCharacterLength,
                placeholder: "郵便番号を入力する",
                keyboardType: .numberPad
            )
            .onReceive(
                viewModel.$zipCode.dropFirst(),
                perform: { zipCode in
                    if maxCharacterLength == zipCode.count {
                        viewModel.apply(
                            .onSearchZipCode(zipCode)
                        )
                        self.closeKeyboard()
                    } else {
                        // なにもしない
                    }
                }
            )

                        ... 一部省略

            if viewModel.cities.isEmpty {
                Text("入力した郵便番号は存在しませんでした。\n再度入力をお試しください")
                    .font(.system(size: 11))
                    .foregroundColor(Color("Error"))
                    .multilineTextAlignment(.center)
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(.top, 24)
            } else {
                VStack(alignment: .leading, spacing: 0) {
                    ForEach(Array(viewModel.cities.enumerated()), id: \.offset) { index, city in
                        Text("\(city.prefectureName) \(city.cityName1) \(city.cityName2)")
                            .font(.system(size: 12))
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .contentShape(RoundedRectangle(cornerRadius: 20))
                            .onTapGesture {
                                viewModel.apply(.onChangeViewStateTapped(.confirm(city: city)))
                            }
                        if index < viewModel.cities.count - 1 {
                            Divider()
                                .padding(.leading, 15)
                        } else {
                            Divider()
                        }
                    }
                }
            }
            Spacer()
        }
        .padding(.top, statusBarHeight())
    }
}

また、ViewModelとUIで単一方向のバインディングを実現したいので、ViewModelは外から入力値を受け取ることができるように以下のprotocolに準拠させておきます。

protocol UnidirectionalDataFlowType {
    associatedtype InputType

    func apply(_ input: InputType)
}
final class DiagnosisRegionSelectViewModel: UnidirectionalDataFlowType {

    typealias InputType = Input

    private var cancellables: [AnyCancellable] = []
    private let disposeBag = DisposeBag()

        // Combine
        private let onSearchZipCodeSubject = PassthroughSubject<String, Never>()

    // MARK: Input
    enum Input {
        case onSearchZipCode(String)[f:id:ohayoukenchan:20220329104948p:plain][f:id:ohayoukenchan:20220329104948p:plain]
                ... 略
    }

    func apply(_ input: Input) {
        switch input {
                case .onSearchZipCode(let zipCode): onSearchZipCodeSubject.send(zipCode)
                ... 略
        }
    }

                ...

こうすることでviewModelの外から viewModel.apply(.onSearchZipCode(zipCode))のようにviewModelへ値を流すことができます。swiftUIの .onTapGesture に複雑な処理を書かないことでコードの見通しが良くなっていると感じています。

最後に

iOS13だとGeometryReaderをうまく初期化できなかったり、.ignoresSafeArea(.keyboard, edges: .bottom) が非対応なのでkeyboardを開いたときに画面を押し上げる処理をわざわざ自前で用意しないといけなかったり NSTextAttachment の色が変わらないなど、毎施策必ずといっていいほどiOS13への対応を行っていました。追加でiOS13向けの対応をしなければいけないことを考えると、サポートバージョンがiOS14以降になってからSwiftUIを導入したほうが無難かなという印象です。

近いうちに弊社アプリもサポートバージョンの見直しを行い、iOS14以降でサポートされている StateObject や LazyVGrid なども使えるようになり、ますます開発が楽しくなってきそうです。今後も引き続きシステムを長持ちさせる力を養っていくぞい。

SageMakerとStep Functionsを用いた機械学習パイプラインで構築した検閲システム(後編)

皆さん,こんにちは!機械学習エンジニアの柏木(@asteriam)です.

今回は前回のエントリーに続いてその後編になります.

tech.connehito.com

はじめに

後編は前編でも紹介した通り以下の内容になります.

  • 後編:SageMakerのリソースを用いてモデルのデプロイ(サービングシステムの構築)をStep Functionsのフローに組み込んだ話
    • モデル学習後の一連の流れで,推論を行うためにモデルのデプロイやエンドポイントの作成をStep Functionsで実装した内容になります.

今回紹介するのは下図の青枠箇所の内容になります.

検閲システムのアーキテクチャー概略図


目次


Step Functionsを使ってサービングシステムを構築する方法

Step Functionsのグラフインスペクターに示された処理のうち赤枠部分が今回の処理になります.

No. ステップ名 SageMakerのアクション 処理内容
5 Model-Creating-Step CreateModel 推論コンテナの設定とモデルの作成
6 EndpointConfig-Step CreateEndpointConfig エンドポイントの設定
7 Endpoint-Creating-Step CreateEndpoint エンドポイントの作成とモデルのデプロイ

Step Functionsのグラフインスペクター

サービングシステムを構築するために,3つの処理をStep Functionsに組み込んでいます.

  1. モデルの作成と推論コンテナの設定
  2. エンドポイントの構成を設定
  3. エンドポイントの作成とモデルのデプロイ

また,サービングシステム・ML API・Clientの関係性を説明するために,システム全体から該当箇所を切り取った図を下に載せています.

サービングシステム

それぞれの役割を説明すると

  • Client⇄ML API
    • ClientはML APIに対して,推論を行うために必要なデータをPOSTする
    • ML APIは正常投稿 or 違反投稿どちらかを表すフラグ値(0 or 1)をClientに返却する
  • ML API⇄推論エンドポイント(サービングシステム)
    • ML APIは検閲する生のテキストを情報として詰め込んで推論エンドポイントをinvokeする
# ML APIの推論エンドポイントをinvokeする処理
 
import json
import boto3


# SageMakerクライアントを作成
client = boto3.client("sagemaker-runtime")

# 推論エンドポイントをinvoke
input_text = {"text": "推論対象のデータ"}
response = client.invoke_endpoint(
    EndpointName='エンドポイント名',
    Body=json.dumps(input_text),
    ContentType='application/json',
    Accept='application/json'
)

# 結果を受け取る
result_body = json.load(response['Body'])
# 違反確率
pred = float(result_body['predictions'])
# 結果の表示
print(pred)
  • サービングシステムはテキストの前処理を行った後に学習済みモデルによる推論を行い,違反確率をML APIに返却する
  • サービングシステムはS3に保存されているモデルアーティファクトをロードしてデータを待ち受けている

それでは,サービングシステムを構築する部分を紹介していきます.

学習済みモデルを含んだ推論コンテナの設定(モデルの作成)

この処理ステップでは,「モデルの作成」を行います.この処理を行う上で用意するコードは以下になります.

今回も公式のサンプルコードを参考にしたので,確認してみて下さい.
参考: amazon-sagemaker-examples/advanced_functionality/scikit_bring_your_own

用意するコード

  • Dockerfile.cpu(今回はgpu版のDockerfileも使用しているため.cpuを付けて区別しています)
    • 推論エンドポイントとしてデプロイするコンテナ
    • ファイル内でserve.pyの実行権限を与えておく必要があります
  • serve.py
    • NginxとGunicornを起動するPythonスクリプトで,コンテナ起動時に実行されるスクリプト
      • 実行されるコマンド: docker run <イメージ> serve
    • 公式のサンプルをそのまま流用
  • inference.py
    • Flaskアプリで,独自の処理を書くことができ,リクエストに応じて機械学習モデルの読み込みや推論処理を行う
      • 今回は生データを受け取り,シーケンスに変換し推論を行う
    • ヘルスチェック時にモデルのロードを行う
# inference.py
"""推論を行うflaskサーバー
    生のテキストデータを受け取り,モデルに入力できる形式に変換する
    BERTモデルに変換したデータを入力することで推論を行う
"""

import json
import os
import sys
import traceback
from typing import List, Tuple

import numpy as np
from flask import Flask, Response, jsonify, make_response, request

# Tensorflow
import tensorflow as tf

# Transformers - Hugging Face
from transformers import AutoTokenizer, TFBertModel

# モデルに使用するパラメータ
MAX_LENGTH = 512
MODEL_NAME = 'cl-tohoku/bert-base-japanese-whole-word-masking'
SAVED_MODEL_NAME = 'bert_model.h5'

# 後述のCreateModelのパラメータModelDataUrlに指定するS3に置かれたモデルファイルパスと同期している
prefix = "/opt/ml/"
model_path = os.path.join(prefix, "model")

tokenizer_bert = AutoTokenizer.from_pretrained(MODEL_NAME)


def text2features(texts: List[str], max_length: int) -> List[Tuple[np.ndarray, np.ndarray, np.ndarray]]:
    """テキストのリストをTransformers用の入力データに変換

    input_ids, attention_mask, token_type_idsの説明はglossaryに記載されている
    cf. https://huggingface.co/transformers/glossary.html

    Args:
        texts (List[str]): 分類対象のテキストデータが入ったリスト
        max_length (int): 入力として使用されるシーケンスの最大長

    Returns:
        List[Tuple[np.ndarray, np.ndarray, np.ndarray]]: input_ids, attention_mask, token_type_idsが入ったリスト
    """
    shape = (len(texts), max_length)
    input_ids = np.zeros(shape, dtype="int32")
    attention_mask = np.zeros(shape, dtype="int32")
    token_type_ids = np.zeros(shape, dtype="int32")

    for i, text in enumerate(texts):
        encoded_dict = tokenizer_bert.encode_plus(text, max_length=max_length, pad_to_max_length=True)
        input_ids[i] = encoded_dict["input_ids"]
        attention_mask[i] = encoded_dict["attention_mask"]
        token_type_ids[i] = encoded_dict["token_type_ids"]

    return [input_ids, attention_mask, token_type_ids]


class ScoringService(object):
    """モデルのロードと受け取ったデータから推論を行う
    """
    model = None

    @classmethod
    def get_model(cls):
        """事前にロードできていない場合はモデルをロードする
        """
        if cls.model is None:
            cls.model = tf.keras.models.load_model(os.path.join(model_path, SAVED_MODEL_NAME), compile=True)

        return cls.model

    @classmethod
    def predict(cls, input: List[Tuple[np.ndarray, np.ndarray, np.ndarray]]) -> float:
        """入力データに対して,推論を行う
        Args:
            input (List): 推論対象のデータで,リストの要素に対して推論を行う
        """
        loaded_model = cls.get_model()

        return loaded_model.predict(input)


# サービング予測用のflaskアプリ
app = Flask(__name__)


@app.route("/ping", methods=["GET"])
def ping():
    """コンテナの動作とヘルスチェックを行う,モデルのロードが成功すればヘルス判定される
    """
    health = ScoringService.get_model() is not None

    status = 200 if health else 404
    return Response(response="", status=status, mimetype="application/json")


@app.route("/invocations", methods=["POST"])
def inference():
    """
    毎分毎にデータが送られてきて,リアルタイムで推論を行う.
    テキストデータを受け取り,モデルが受け入れられる形式に変換を行い,予測確率(0.0~1.0)を返す.
    """
    # データを受け取って,モデルに入力できる形式に変換する
    data = request.get_data().decode("utf8")
    data = json.loads(data)
    text = text2features([data['text']], MAX_LENGTH)

    predictions = ScoringService.predict(text)
    return make_response(jsonify(predictions=str(predictions[0][0])), 200)
  • nginx.conf
    • Nginxの設定ファイル
    • 8080番ポートで /pingもしくは /invocationsにアクセスがあった場合に,Gunicornに転送する
    • 公式のサンプルをそのまま流用
  • wsgi.py
    • Gunicornの設定ファイル
    • 推論コード(inference.py)をimportする

用意するコードからわかるように,サービングシステムの実態はWeb ServerにNginx,Application ServerにGunicornを使いフレームワークとしてFlaskを利用しています.
これらのコードを用意したら,イメージをECRに登録し,Step Functionsの定義設定を行います.

CreateModelで主に設定する内容

  • モデルに名前を付ける
  • 推論コンテナの設定
    • 推論コード
    • サーブファイル
    • アーティファクト(=モデル)のパス設定
  • イメージ
"Model-Creating-Step": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sagemaker:createModel",
  "Parameters": {
    "PrimaryContainer": {
      "ContainerHostname.$": "States.Format('{}-{}', 'prod-sample-con', $$.Execution.Name)",
      "Environment": {
        "PYTHON_ENV": "prod"
      },
      "Image": "<アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/sample:latest-cpu",
      "Mode": "SingleModel",
      "ModelDataUrl.$": "$.ModelArtifacts.S3ModelArtifacts"
    },
    "ExecutionRoleArn": "arn:aws:iam::<アカウントID>:role/StepFunctions_SageMakerAPIExecutionRole",
    "ModelName.$": "States.Format('{}-{}', 'prod-sample-m', $$.Execution.Name)"
  },
  "Catch": [
    {
      "ErrorEquals": [
        "States.ALL"
      ],
      "Next": "NotifySlackFailure"
    }
  ],
  "ResultPath": null,
  "Next": "EndpointConfig-Step"
}
  • ModelDataUrl: TrainingJobの出力結果から参照しており,モデルが保存されているS3のパスを指定します.ここで指定したパスが’/opt/ml/model’に同期されるので,推論コードで呼び出してモデルをロードすることができます.
  • ExecutionRoleArn: ロールにアタッチするポリシーはSageMaker Rolesを参考にしてみて下さい.ここで嵌ってしまったのですが,Actionに"iam:PassRole"が必要になるので注意です.

エンドポイントの構成を設定

この処理ステップでは,モデルをデプロイするために使用する「エンドポイントの構成を作成」を行います.

CreateEndpointConfigで主に設定する内容

  • デプロイするモデルの指定(CreateModel時に付けたモデルの名称)
  • プロビジョニング用のリソース
  • エンドポイント構成の名前
"EndpointConfig-Step": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sagemaker:createEndpointConfig",
  "Parameters": {
    "EndpointConfigName.$": "States.Format('{}-{}', 'prod-sample-ec', $$.Execution.Name)",
    "ProductionVariants": [
      {
        "InstanceType": "ml.t2.large",
        "InitialInstanceCount": 1,
        "ModelName.$": "States.Format('{}-{}', 'prod-sample-m', $$.Execution.Name)",
        "VariantName.$": "States.Format('{}-{}', 'prod-sample-v', $$.Execution.Name)"
      }
    ]
  },
  "Catch": [
    {
      "ErrorEquals": [
        "States.ALL"
      ],
      "Next": "NotifySlackFailure"
    }
  ],
  "ResultPath": null,
  "Next": "Endpoint-Creating-Step"
}
  • InstanceType: 推論サーバーのマシンスペック(インスタンスタイプ)をここで決めます.今回は最低スペックのml.t2.mediumだとメモリ不足になったので,メモリ8GBのマシンを選択しました.この辺りは常時稼働しているので費用面と相談しながらスペックを決める必要があると思います.

エンドポイントの作成とデプロイ

この処理ステップでは,エンドポイント設定を用いて「エンドポイントの作成」を行います.ここで最終的に設定されたリソースを起動し,モデルをその上にデプロイします.

CreateEndpointで主に設定する内容

  • デプロイするモデルの指定(CreateModel時に付けたモデルの名称)
  • 使用するエンドポイント構成の指定(CreateEndpointConfig時に付けたエンドポイント構成の名称)
  • エンドポイントの名前
"Endpoint-Creating-Step": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sagemaker:createEndpoint",
  "Parameters": {
    "EndpointConfigName.$": "States.Format('{}-{}', 'prod-sample-ec', $$.Execution.Name)",
    "EndpointName.$": "States.Format('{}-{}', 'prod-sample-e', $$.Execution.Name)"
  },
  "Catch": [
    {
      "ErrorEquals": [
        "States.ALL"
      ],
      "Next": "NotifySlackFailure"
    }
  ],
  "End": true
}

処理が正常に完了するとSageMakerのコンソール上でエンドポイントを選択すると,指定したエンドポイント名のステータスが「InService」になっていることを確認できます.

SageMakerのコンソール画面 - エンドポイント

また,エンドポイントを誤って削除したり,想定とは違う状態だった場合にロールバックが必要になることがありますが,これはモデルとエンドポイント設定が残っていればいつでも復元可能です.エンドポイントの作成は手動でもできるのでSageMakerのコンソールから設定すると良いと思います.

機械学習システムを開発して

今回新しく検閲システムを開発し,その中でデータ抽出からモデルの学習,そしてモデルのデプロイまで一気通貫した機械学習パイプラインを構築しました.このプロジェクトでは,推論システムも構築する必要があったため,そもそもStep Functionsでモデルのデプロイまで持っていけるのかというところから技術検証したり,推論速度といった非機能要件なども検討して処理を考える必要があったりと難しい部分もありました.また,PoCは別のメンバーが担当していたこともあり,Jupyter Notebookからプロダクション用のシステムに合わせたコードを作り上げる部分や再現性を取る部分でも苦労がありました.

これらの苦労の甲斐あって?無事に本番稼働しているこのシステムの状況としては,コスト削減という部分で,当初の期待通りxx万円/月のカットに寄与できていたり,サービス品質向上という部分では,質問の回答率が上がるといった成果が出ています.

一方で,推論の精度面で多少の検知漏れがあったりと少し改善が要求されたりする可能性があり,この辺りは継続的に改善が必要で,まさにMLOpsだなと感じています.

また,この取り組みは全ての投稿をチェックすることから,より違反確率が高い投稿のみを重点的にチェックすることができるため,作業量が減り作業者の精神的負荷が減ったり,作業効率化も上がるといった作業者側のメリットだけでなく,モデルが違反確率が高いと返した投稿の中にも問題ない(正常)投稿も含まれているため,これらを人間が正しく判定し直すことで,今後のモデル改善時に使える有効なアノテーションデータとして蓄積することができるメリットもあります.これらの取り組みはまさに「Human-in-the-Loop」が上手く機能している状態ではないでしょうか.

おわりに

今回は前編・後編と2つの記事に分けてSageMakerとStep Functionsを用いた機械学習パイプラインにより構築した検閲システムの内容を紹介しました.特にStep FunctionsでのTrainingJobの活用例やモデルのデプロイ部分を組み込んだパイプラインに関する事例はあまり公開されていない内容かと思うので,是非参考にして頂ければと思います.

今回の取り組みはCSチームと連携して進めたことにより良い成果が出つつあると思うので,これからもサービスの品質向上やグロースに対して他チームと協力する中で機械学習を導入することでよりその価値を発揮していければと思います.

最後に,コネヒトではプロダクトを成長させたいMLエンジニアを募集しています!!(切実に募集しています!)
もっと話を聞いてみたい方や,少しでも興味を持たれた方は,ぜひ一度カジュアルにお話させてもらえると嬉しいです.(僕宛@asteriamにTwitterDM経由でご連絡いただいてもOKです!)

www.wantedly.com

Jest + react-testing-library でフロントエンドテストをコツコツ積み上げている話

こんにちは。コネヒト歴7ヶ月目のWebエンジニアの古市です。

私の所属するチームではReactで構築されたCMSを開発しています。 Atomic Designに則り、コンポーネントを Atoms/Molecules/Organisms/Pagesの区分で作成しています。このうち、Atoms,Molecules,OrganismsについてはJest+react-testing-libraryの組み合わせで必ずテストを書くようにしています。 今回は実際に書いているテストコードを例に挙げながら、どのような点をテストコードで担保しているか、また、テストを積み重ねるための施策について説明いたします。

具体的なテストコード

これは業務で書いているテストコードを抽象化した一例です。 以下のような構造のコンポーネントのテストだとイメージしていただければと思います。

  • 名前が表示される
  • アバター画像が表示される
  • コメントを記入するinputと、付随するラベルが存在する
  • 「更新」と書かれたボタンを押下で変更内容をupdateする
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { SomeComponent } from 'components/organisms/SomeComponent'
import client from 'api/client' // 実装したAPI

// post時のAPIをモック化
jest.mock('api/client')
const mockedAPI = client.post as jest.Mock

// コンポーネントに投入する初期モックデータ
const mockData: MockData = {
  id: 123,
  name: 'コネヒト太郎',
  image: 'https://some-url/320x480.png',
  label: 'テキスト',
  comment: '',
}

まずテストを書くために必要なライブラリとテスト対象のコンポーネントをimportします。 そしてコンポーネントに流し込むモックデータを一番最初に定義します。

// describe -> test の順番で記述 
describe('<SomeComponent />', () => {
  test('should render component', () => {
    const mock: MockData = mockData

    render(<SomeComponent mockData={mock} onClickUpdate={() => {}} />)

    // 名前、アバター画像、ラベルの描画チェック
    expect(screen.getByText(mock.name)).toBeInTheDocument()
    expect(screen.getByRole('image')).toBeInTheDocument()
    expect(screen.getByLabelText(mock.label)).toBeInTheDocument()

    // textareaの初期値チェック
    expect(screen.getByRole('textbox', { name: 'テキスト' })).toHaveDisplayValue(mock.comment)
    // ボタンの描画チェック
    expect(screen.getByRole('button', { name: '更新' })).toBeInTheDocument()
  })

最初にコンポーネントが描画されることをテストコードで確認します。 testing-libraryを使う場合、screen.getBy*というQueryメソッドでDOM要素の有無を特定することが定石ですが、アクセシビリティに則り使用する優先順位が以下のように定められています。

  1. getByRole
  2. getByLabelText, getByPlaceholderText
  3. getByText, getByDisplayValue

テストコードでDOM要素を特定するときも、アクセシビリティの観点からなるべくこの優先順位を無視しないよう心がけています。(どうしても難しい時にtestByIdなどを活用します。) 他にも、書くメソッドの使用優先順位が定められているので詳しくは公式サイトのリファレンスをチェックしてください。

また、過去に自身でQueryの優先順位について整理してLTで発表したスライドもあるので、こちらもぜひご覧いただけると幸いです。

React Testing Library の Query について整理してみた - Speaker Deck

それではテストの続きです。 2番目のテストスイートでは要素の変更が反映されるかをチェックしていきます。

  test('Events should be called', () => {
    const onClickUpdate = jest.fn()
   
    render(<SomeComponent mockData={mock} onClickUpdate={onClickUpdate} />)

    // テキストアイテムのテキスト変更のonChangeイベントをテスト
    const textareaContent = screen.getByRole('textbox', { name: 'テキスト' })
    fireEvent.change(textareaContent, { target: { value: 'テキストを変更しました' } })
    expect(screen.getByDisplayValue('テキストを変更しました')).toBeInTheDocument()

    // 更新ボタンのonClickをテスト
    fireEvent.click(screen.getByRole('button', { name: '更新' }))
    expect(onClickUpdate).toHaveBeenCalled()
  })

「更新」をボタン押下したときに呼び出されるメソッドはjest.fn()でモックしておきます。 textboxへの新しい値の入力や、ボタンのクリックなどのイベントはfireEventメソッドでモックし、伝達することができます。

最後に、テキストの更新が意図通り行われた時、APIにリクエストが送られているかをテストします。

  test('Save API should be called', async () => {
    mockedAPI.mockResolvedValueOnce({
      status: 200,
      data: {
        item: { ...mock, comment: 'テキストを変更しました' },
      },
    })

    const mockUpdated = { ...mock, comment: 'テキストを変更しました' }
    const onClickUpdate = jest.fn()

    render(<SomeComponent mockData={mockUpdated} onClickUpdate={onClickUpdate} />)

    // テキストアイテムの文言を変更
    fireEvent.click(screen.getByRole('button', { name: '更新' }))
    await waitFor(() => expect(mockedAPI).toHaveBeenCalledTimes(1))

    expect(screen.getByDisplayValue(mockUpdated.comment)).toBeInTheDocument()
  })
})

コンポーネントに変更後の comment を流し込み、更新ボタンを押した時に、テストコードの冒頭でモック化したAPIが呼び出され、正常なステータスと更新後の値がコンポーネントに反映されているかを上記でテストしています。モック化したAPIを叩く時は、waitFor(() => ...の前にawaitを記述しないと正しく結果が得られません。 以上は一例ですが、正常系のテスト以外でも、コンポーネントによっては4xxのバリデーションエラーや5xxのサーバーエラー発生時の挙動をテストコードで補完する場合があります。

テストを積み重ねるための工夫

開発者がテストを必ず書くことを促すため、プロジェクトのリポジトリ内にCodecovをGitHub ActionのWorkflowに導入しています。Pull Requestを送信するとリポジトリの最新(main)と当該PRのdiffを視覚的に把握することができます。また、最低カバレッジ率を .yml ファイル内に記載することができます。 pages以外のコンポーネントを作成してPull Requestを送信した時、テストが書かれていないまたはテストケースが足りていない場合に、Codecovがカバレッジ低下の警告を出し、テストケースを追加すべき場所にコメントを自動的に付けます。

  • Codecovが表示するdiff

  • テストが不足している場合に出す警告のコメント

上記のようにカバレッジが下がっている状態ではCIが通過せず、コードレビューに提出することができません。独力で正確にテストコードを追加できない場合には他のメンバーにアドバイスをもらったり、モブプロ / ペアプロで解決させるように取り組んでいます。現時点ではカバレッジ率を90%にしていますが、これをもう1段階高く設定することを目標にしています。

終わりに

テストコードの解説が大半を占めてしまいましたが、チーム内でのフロントエンドテストへの取り組みについて説明いたしました、まだJestやtesting-libraryの使い方でつまづく時があるため、社内で知見を共有しあい、今後も真摯にテストに向き合いつつツールへの習熟度を高めていきたいと思います。最後までお読みいただきありがとうございました。

PR

コネヒトでは React を使ってテストも書きたいエンジニアを募集しています!

hrmos.co

Win Sessionで元気に目標を達成するチームづくり

こんにちは、コネヒトでエンジニアをやっているあぼ(aboy)です ԅ( ˘ω˘ԅ)

今回は私の所属するテクノロジー推進部というチームで実施しているWeekly Win Session(ウィンセッション)について紹介したいと思います。始めてから5ヶ月ほど経ち、チームのイベントとして定着しました。Win Sessionのひとつの事例として何かの参考になれば幸いです。

ちなみにWin Sessionとは以下のようなもので、OKRの文脈で出てくることが多いです。

週の終わりに今週はどんな結果だったのかを確認し、立て直し策を具体的に決めるまで行うことを主目的にミーティングを行います。これを、ウィンセッションと呼びます。ここで大切なことは、結果にかかわらず、各メンバーが高い目標に挑んだことを承認・賞賛することです。

奥田和広. 本気でゴールを達成したい人とチームのためのOKR (Japanese Edition) (p.143). Kindle 版.

一般的なWin Sessionの説明はさらっと引用での紹介にとどめ、さっそく私のチームの話に入ります。

なぜWin Sessionを始めたか

理由は大きくわけて2つあります。

  • チームが元気な状態で目標を達成できるようにする
    • コネヒトでは基本的に6ヶ月ごとに目標を立て、達成させるための作戦を考え、動いていくのですが、終わり間際に追い込み疲れている印象がありました。
    • なのでチームの作戦を立てる段階で「元気な状態で目標を達成する」というゴールイメージをつくりました。
    • 短いサイクルで体力と気力を回復しながら前に進んでいくための手段として、賞賛を行うWin Sessionは相性が良さそうに見えました。
  • 今週も頑張った!来週も頑張るぞ!って思える場を作りたい
    • 私のチームでは毎週の定例で目標の進捗共有や議論などを行っていて、どちらかというと課題発見や課題解決に重きが置かれていました。これまでを振り返りつつ先のことを考える重要な時間です。
    • そういった目標達成に向けたカッチリした共有や議論と、やっていることや業務連絡など非同期でも十分な共有、そのどちらでもない「やったことを称える時間」をつくることで、メリハリが生まれるのではないかと考えました。

どういうふうにやっているか

ルールというほどカタいものではないですが、やっていくうちにある程度型ができてきたので紹介します。やり方はつどつど見直しています。

  • 毎週金曜日の夕方に開催
    • 毎週開催することによって1週間のリズムが生まれること、またWin Sessionのような良い気持ちで終わるイベントで1週間を締めることで、1週間を労い、来週のパワーに繋がることを期待しています。
    • 開始当初は18:30開始としていましたが、そもそもコアタイム外であること、コロナ禍における家庭環境、Win Sessionがあるから早めに上がれないことは避けたい、など考慮して現在は17:00開始で定着しました。
    • お酒を飲みながら参加しても良い(ただし飲んだらその後仕事は禁止)というルールで始めましたが、まだお酒を飲む人は現れません。(そして私も飲んでない)
  • チーム日報を見返しながら1人ずつ発表
    • 私のチームでは、メンバー全員で同時編集する形で日報を書きながら日々仕事をしており、その日報を1週間分見返しながら、自分のWinを3~5分程度で発表していきます。
    • 自分の仕事を振り返り、自分はこんなことやったんだぞってアピールしみんなで称えます。
    • 1週間分のチーム日報をまとめて振り返るのが結構大変なので、最近は日々発見したWinを1つの場所(Notionを使っています)に溜めておき、それを見ながら進めるようなやり方にトライしています。
  • 他の人のWinも見つける
    • チーム日報の効果として、自分以外のチームメンバーの仕事が目に触れやすいというのがあります。専門分野が違うメンバーが集まり共通の目標を追う僕たちにとって、自分の専門分野ではない領域の仕事に興味を持ったりフォローしたりすることには価値があります。
    • ですので、その人自身はWinだと思っていないようなことを発見してあげることもあります。
  • 議論はしない
    • 議論をする場ではないため議論はしません。これと開催時間が明確なルールかもしれません。
  • 物理的に拍手 👏 する
    • Win Sessionは今まで全てオンラインで行っていますが、Winの発表が終わったら「お疲れ様でした」と共に物理的に拍手 👏 をするようにしています。これは自分や他人のWinを聞くと自然と拍手したくなったからしてる、以上の理由はないのですが、結構気持ちがいいです。そういえばリモートワークになってから、ビデオチャットに「88888888888」みたいに書き込むことはあっても物理的な拍手することってあんまりないな、と思っています。皆さんはどうですか...? ԅ( ˘ω˘ԅ)
  • 「良い週末を!」で締める
    • 金曜日の夕方に開催しているのでこうしています。締まりが良いですし、締め方に悩む必要もないので一石二鳥です。

f:id:aboy_perry:20220325144304p:plain
とある週のWin Sessionの様子(Notion)

どんな効果を実感しているか

5ヶ月ほど続けて、チームメンバーからは以下のようなフィードバックが集まっています。

  • 準備なしで参加できるのがGood(ゆるーい感じのコミュニケーションの場という感じ)
  • 1週間やったった!来週もやったるか!という感情が以前より湧くようになった
  • 週のしめくくりとしてちゃんと終わりを意識できるのが良い
  • 1週間の締めのイベントとして定着したのは間違いない感じ
  • ゆるいけれど「1週間の自チームからみたコネヒトの様子」が可視化されるようになった感じがある
  • 今週もお疲れさまでしたーと解散していくのはとてもよい!
  • 締めの今週もお疲れさまでしたでzoom越しで 👋 するの好き
  • みんなのwinを探す方式を取っているので、自然と他人の良かったところ(≒アウトプット)を探す癖?みたいなものがついたかも?(本当か?)
    • 今までよりチームメンバーへの興味度合いが上がっているのかもしれない(本当か?)

チームの1週間にメリハリをつけるイベントとして定着したといえます。

一方で、以下のようなフィードバックもあります。

  • 他薦もアリになってからは自薦のWinが減った感覚があり、自薦形式のほうが聞いていて好きかもしれない
  • Winまでいかなくても、強いて言うならこんなことやって個人的には小さなWinです、みたいなのが個々人フォーカスして聞けるのもいいよね

自分で自分のWinを発表するという形から、他人のWinを紹介する(「こんなことやっていたから称えたい」)ケースが増え、ここまではやり方の範疇ですがもっと言うとチーム外の人のWinが出てくることも増えました。この辺りは最初に決めた仕組みに固執せず柔軟にチームで考え決めていきたいところです。目的が変わると参加者の期待値も変わるのでそこは丁寧にいきたいところです。

それから、Win Sessionがメリハリをつけるようなイベントだからこそのお悩みもありました。

  • 業務が立て込んでいるとWin Session後にまたガッツリ仕事に戻るのが大変

あとは、お酒でも飲みながらワイワイやる会があってもいいかもしれません。

  • Win Session後すぐに上がってお酒を飲むとかもうちょっとやりたかった
    • たしかに初期コンセプトは飲みながらでもみたいなノリだったよね。やれてない

...で、結局チームの目標は達成できたのかというと、無事達成できそうです。これが一番嬉しいです。Win Sessionとの因果関係は分かりませんが、これはチームのためのイベントなので、「チームのためになっているか?」を常に考えこれからもチームで試行錯誤していきます。

おわりに

私のチームでのWin Session事例を紹介しました!この記事を書くにあたって初めてWin Sessionをやった直後の反応(Slack)を見返してみました。最初なので手探りでしたが、何となくいい感じだったな〜と思えました。Win Sessionが気になった方は、ぜひ一度試しみてください〜。

f:id:aboy_perry:20220315022818p:plain
初めてWin Sessionをやったときの反応(最初の頃は18:30開始でした)

We are hiring!!

コネヒトでは、プロダクトを成長させたいWebエンジニアを募集しています!

ライフイベント、ライフスタイルの課題解決をするサービスに興味がある方 是非お話できれば嬉しいです。

下記リンクからお気軽にご連絡お待ちしています!

www.wantedly.com

既存プロダクトのCakePHPのアップグレード戦略

既存プロダクトのCakePHPのアップグレード戦略

こんにちは。サーバーサイドエンジニアをやっている西中です。

花粉症に悩まされているので最近空気清浄機を購入しました。こころなしか症状が緩和している気がしています。

前回はCakePHP4.3にアップグレードする際に躓きがちなphpunitの変更ポイントをいくつか紹介させていただきました。

実はこのCakePHPのアップグレード対応は段階的に行っていました。

CakePHP段階的なアップグレード対応

私が携わっているこのプロダクトは2018年11月にリリースされました。 リリースした時点ではCakePHPのバージョンは3.6でした。

いきなりCakePHP3.xからCakePHP4に上げてしまうとアップグレード対応の差分が大きくなってしまい、対応に時間がかかってしまうという問題があるため、段階的にアップグレード対応しようという判断になりました。

少し話が逸れてしまいますが、弊社では各開発チームごとにスクラムを組んでアジャイル開発を行っています。アジャイル開発と言っても実際の運用はチームごとに異なりますが、当プロダクトでは1スプリントの中で何度もリリースすることがあります。

f:id:satoshie:20220323174103p:plain:w300
GitHubフローにおけるブランチ開発

また、弊社ではGitHubフローに沿って開発を行っています。 このアップグレード対応という「保守対応」と、アウトカムを支えるための「施策運用対応」を並行で進めることになるため、ブランチ運用としてはアップグレード対応用のFeatureブランチとそれぞれの試作用のFeatureブランチが必要になってきます。

施策運用対応のためのブランチは都度都度mainブランチにマージされていくため、保守対応のためのブランチとの差分が増えていき、定期的にmainブランチを取り込みアップグレード版に合わせた形に都度都度修正する必要が出てきます。 (最新のパッチを保守対応用ブランチに適用させていくバックポート対応のイメージです)

この都度都度修正の対応が大きめの施策になればなるほどCakePHPのバージョンの差異に合わせた修正の規模が大きくなってしまう問題もあり、その分工数が余計にかかってしまいます。

これらの事情から、保守対応ブランチをmainブランチへマージするまでの時間を短くするために、あえて段階的にアップグレード対応を行うということになったのです。

また、以前CakeFestで紹介されたスライド(CakePHP - The Road Ahead)でも、2.xから3.0.0にアップグレードしたときに変更量が多くて大変だったということが述べられています。

実際にサービス提供しているプロダクトの場合、安全に倒すためにも段階的なリリースを計画するのが良さそうですね。

CakePHP3.6から3.10へ

まず、CakePHPのバージョンを3.6からCakePHP3.xの最新のバージョンである3.10にアップグレードしました。 実はこの3.6から3.10にアップグレードする際の変更量が一番多かったのではないのかと思っています。

一番大きな影響が受けたのがテストのFixture周りです。

今までは Model クラスをテストケース内で使用する際には TestCase のメンバ変数内で実際の DBに格納されているテーブル名に合わせてModel名を以下のように Snake Caseで記述していましたが、CakePHP3.6以降ではModel名をUpper Camel Caseで記述する必要があります。

TestCase::$fixtures にてアンダースコアー形式のフィクスチャー名を使用することは非推奨です。 代わりにキャメルケース形式の名前を使用してください。例えば、 app.FooBar や plugin.MyPlugin.FooBar です。 3.7 移行ガイド - 3.10 より引用

public $fixtures = [
    'app.cities',
    'app.countries',
    'app.country_languages',
];
public $fixtures = [
    'app.Cities',
    'app.Countries',
    'app.CountryLanguages',
];

ロジックの変更対応ではないので、一つ一つ対応していけば良いのですが、テストケースの数が多ければその分対応する場所も多くなってしまいます。

変更量が多いということはテストファイルによって、ある程度テストの網羅性が担保されているとも考えられるので、この変更は喜んで進めていきましょう。

さいごに

どこの会社・プロダクトでも保守対応は置いてけぼりになりがちになってしまい、フレームワークのバージョンが置いていかれてしまうことが多いと思います。 セキュリティパッチが当てられたりと、フレームワーク側で対応が進められている中で、古いバージョンのまま放置しておくとセキュリティリスクも上がってしまいます。

アウトカムのリリーススケジュールと並行して計画的にバージョンアップを行えるようにしていきたいですね!

あわせて読みたい