LayerX エンジニアブログ

LayerX の エンジニアブログです。

pprof を使って Fact Base に改善を行った話

こんにちは。バクラクビジネスカードでエンジニアをしている @onsd_ です。最近はランニングを始めて、皇居周りだったり山だったりいろいろ走っております。Strava 友達募集中です。

この記事は LayerXテックアドカレ 31日目の記事です。 前回はkikuchyさんによる なぜバクラク申請・経費精算はFlutterでアプリの作り直しをしているのか でした。アプリを作り直す理由がLayerXらしいなぁと思いました。興味がある方はぜひそちらもご覧ください! 次回は ar_tamaさんが担当されます。お楽しみに!


この記事では、アプリケーションサーバで起きた問題について調査した手順を残します。ツールを使って調査を進める方法について参考になれば嬉しいです。

開発環境でのみ起きるOOM

アプリケーションの処理として、ファイルを受け取り S3 にアップロードするという処理があります。

この処理について、まず受け取れるファイルサイズの上限を引き上げる対応をしました。 (※ GraphQLサーバを実装するために、gqlgen を利用しています)

   srv.AddTransport(transport.MultipartForm{
-      MaxUploadSize: 50 * 1 << 20, // 50MB,
-      MaxMemory:     50 * 1 << 20, // 50MB,
+       MaxUploadSize: 80 * 1 << 20, // 80MB,
+       MaxMemory:     80 * 1 << 20, // 80MB,
    })

上限を引き上げ、手元でファイルをアップロードしたところ正常に動作していそうでした。 そのため、マージして様子を見ていたところ、たまにECSのタスクが死んでいる事に気づきました。

開発環境でファイルをアップロードしてみると、50MBのファイルでは問題ないのですが、80MBのファイルをアップロードすると 500 が返ってくる、といった挙動を示していました。

コンテナのログをみてみるとOOMでタスクが終了されていた事がわかったので、調査してみることにしました。

調査:誰がメモリを使っている?

メモリの使用率を計測するため、今回は profile を使ってメモリのプロファイリングをすることにしました。

github.com

profile は、計測でよく使われる pprof を使いやすいようにラップしたライブラリです。

次のように、import して main関数の一番上で呼び出します。

diff --git a/cmd/api/main.go b/cmd/api/main.go
index 024d3e37..7609ffa3 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -24,6 +24,7 @@ import (
+       "github.com/pkg/profile"
 )
@@ -32,6 +33,8 @@ import (
 
 func main() {
+       defer profile.Start(profile.MemProfile).Stop() // メモリの計測をする

この状態でアプリケーションを動作させると、次のように何の計測が有効になっているかと、プロファイリング結果のパスが表示されます。

$ go build ./cmd/api/...
$ ./api
2023/06/21 09:49:12 profile: memory profiling enabled (rate 4096), /var/folders/w4/hsgyyx5508b60xcvtnzhk1hr0000gn/T/profile4014285411/mem.pprof

ここで、80MB のファイルをアップロードしてから、プロファイリング結果をみてみることにしました。

結果の解析には、 go tool pprof コマンドが利用できます。

# go tool pprof <path-to-binary> <path-to-profile-output>
$ go tool pprof api /var/folders/w4/hsgyyx5508b60xcvtnzhk1hr0000gn/T/profile369504276/mem.pprof
File: api
Type: inuse_space
Time: Jun 21, 2023 at 9:53am (JST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 154.14MB, 99.61% of 154.75MB total
Dropped 388 nodes (cum <= 0.77MB)
Showing top 10 nodes out of 37
      flat  flat%   sum%        cum   cum%
  154.14MB 99.61% 99.61%   154.14MB 99.61%  io.ReadAll
         0     0% 99.61%    60.19MB 38.89%  github.com/99designs/gqlgen/graphql/executor.(*Executor).DispatchOperation.func1.1
         0     0% 99.61%    60.19MB 38.89%  github.com/99designs/gqlgen/graphql/executor.(*Executor).DispatchOperation.func1.1.1
         0     0% 99.61%    60.19MB 38.89%  github.com/99designs/gqlgen/graphql/executor.aroundRespFunc.InterceptResponse
         0     0% 99.61%    60.19MB 38.89%  github.com/99designs/gqlgen/graphql/executor.processExtensions.func2
         0     0% 99.61%    60.21MB 38.91%  github.com/99designs/gqlgen/graphql/executor.processExtensions.func3
         0     0% 99.61%    60.21MB 38.91%  github.com/99designs/gqlgen/graphql/executor.processExtensions.func4
         0     0% 99.61%    60.19MB 38.89%  github.com/99designs/gqlgen/graphql/executor.processExtensions.func6
         0     0% 99.61%    60.19MB 38.89%  github.com/99designs/gqlgen/graphql/executor.processExtensions.func6.1
         0     0% 99.61%   154.20MB 99.64%  github.com/99designs/gqlgen/graphql/handler.(*Server).ServeHTTP

これをみると、 io.ReadAll がメモリを使っていることがわかりました。

io.ReadAll は、対象のファイルをメモリにすべて読み込みます。では、80MB弱のファイルを読み込んだだけでなぜ 154.14MB 使用したことになってしまうのでしょうか。

ソースコードを io.ReadAll で検索すると、S3 にアップロードする処理で利用していることがわかりました。今回は、s3Manager を利用して、 io.Reader のまま処理する方向で修正しました。

// コード例
func Upload(ctx Context.Context, body io.Reader) (string, error) {
        id := ulid.MustNew()
        s3Key := "some-path-s3-" + id

-      bodyBytes, err := io.ReadAll(body)
+       uploader := s3manager.NewUploaderWithClient(app.GetS3())
+       _, err := uploader.Upload(&s3manager.UploadInput{
+           Body:   body,
+           Bucket: &bucket,
+           Key:    &s3Key,
+       })
        if err != nil {
            return "", err
        }

-   if err := aws.PutS3Object(bucket, s3Key, bytes.NewReader(bodyBytes), contentType); err != nil {
-      app.LogError(ctx, err).Send()
-      return "", err
-  }
        return s3Key, nil
     }
}

この変更を適応した後、再度ファイルアップロードを行い、計測を行いました。

$ go tool pprof api /var/folders/w4/hsgyyx5508b60xcvtnzhk1hr0000gn/T/profile3099273002/mem.pprof
File: api
Type: inuse_space
Time: Jun 21, 2023 at 10:03am (JST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 75.19MB, 99.44% of 75.61MB total
Dropped 271 nodes (cum <= 0.38MB)
Showing top 10 nodes out of 17
      flat  flat%   sum%        cum   cum%
   75.19MB 99.44% 99.44%    75.19MB 99.44%  io.ReadAll
         0     0% 99.44%    75.19MB 99.45%  github.com/99designs/gqlgen/graphql/handler.(*Server).ServeHTTP
         0     0% 99.44%    75.19MB 99.44%  github.com/99designs/gqlgen/graphql/handler/transport.MultipartForm.Do

どうやら、他にも io.ReadAll を使っている箇所があるようです。

調査:誰が io.ReadAll を呼んでいる?

この段階で、ソースコード上で io.ReadAll が利用されている箇所はありませんでした。

そういった場合の調査に役立つのが、pprof の -png オプションです。

このオプションをつけて実行することで、関数の呼び出し順が図で表示されます。

$ go tool pprof -png api /var/folders/w4/hsgyyx5508b60xcvtnzhk1hr0000gn/T/profile3099273002/mem.pprof > pprof2.png

関数呼び出しのグラフ。`MultipartForm.Do` が `io.ReadAll` を呼んでいる

この図を見ると、ServeHTTP が呼んでいる MultipartForm.Do のなかで io.ReadAll が呼ばれていることがわかります。

MultipartForm.Doの実装を読むと、 MultipartForm.maxMemory とアップロードされたファイルの Content-Length を比較して挙動が変わるようになっていました。

  • Content-Length の方が大きい場合は、一時ファイルを作成してそこに書き込まれます。
  • Content-Length の方が小さい場合は、io.ReadAll を利用してメモリにコピーされます。

今回、80MiB まで受け付ける対応をした時に、maxMemory も増やす対応をしていました。 そのため、大きいファイルがメモリへコピーされるようになりましたが、定義されていたECSのタスクのメモリでは小さかったというのがOOMの原因のようでした。

ここまでわかったので、タスクのメモリを増やす対応を行いました。

おわりに

OOMが起きた!というときに、なぜOOMが起きたのか?をプロファイリングツールを利用することで根拠を持って改善ができたのは良い体験でした。 また、コードを追って行くことで理解も深まったため、一石二鳥でした。

ちなみに Fact Base とは、弊社の行動指針です。 こちらにも興味がある方は、ぜひ LayerX 羅針盤 を御覧ください。


ハタラクをバクラクにするプロダクトを一緒に開発していきたい人を大募集中です! もしご興味ありましたら、ぜひカジュアル面談からお話させてください!

jobs.layerx.co.jp

LayerX Casual Night というお酒を飲みながらカジュアル面談よりカジュアルにゆる~く話せるイベントも開催しておりますので、ぜひご参加ください!

jobs.layerx.co.jp

なぜバクラク申請・経費精算はFlutterでアプリの作り直しをしているのか


バクラク申請・経費精算チームでモバイルエンジニアをしている id:kikuchy です。

埼玉県民として翔んで埼玉の2作目を履修してきました。埼玉県民が東京の池袋に集まって会議しているところや、県民の日にみんなが夢の国に行ってしまうあたりがリアリティあって良かったです。今作も笑わせてもらいました。

この記事はLayerXテックアドカレ2023の30日目の記事です。
前回はチームメイトの @_chocoyama さんによる 【特別対談】 Flutterエンジニアの今オレ x iOSエンジニアの過去オレ でした。chocoさんの時空を超える能力が最大限活かされた、Flutter開発の現場に対する不安が払拭される素敵な記事でしたね!
次回はカードチームエンジニアの Omoriさんの記事になります。楽しみ!


本日は、現在Flutterを使用して再開発しているバクラク申請・経費精算のモバイルアプリについて書かせていただきます。

バクラク申請・経費精算のモバイルアプリの現状

すでにバクラク申請・経費精算のモバイルアプリは各種ストアからダウンロードしてご利用いただけます。

バクラク申請・経費精算

バクラク申請・経費精算

  • LayerX, Inc
  • ビジネス
  • 無料
apps.apple.com
play.google.com

起動していただけるとおわかりになる通り、WebViewでモバイルブラウザ用の画面を表示している、いわゆるWebViewアプリとなっています。
WebViewアプリとして開発しリリースに至った経緯については、過去にこのブログで書いておりますのでそちらを御覧ください。
tech.layerx.co.jp

どうして作り直すことに?

体験が良くなかったから、です。

2022年当時、WebViewアプリにすることを決めた一番大きな理由はエンジニアリソースが少ないことでした。
リソースを節約しつつ、アプリという形でお客様に機能を提供できたので、当初の技術選定としては間違いではなかったと思っています。
アプリをリリースした大きな理由の一つである交通系ICカードの読み取り機能もWebViewのJavaScriptブリッジで実現できましたし、エンジニアが少ないまま機能拡充するという当初の目的は果たせたのです。

しかし、やはりリリースしてから見えてくるものもあります!

アプリを使っていただいたお客様から、現在のWebViewアプリ(Webアプリケーションのスマホ用表示を含む)の使い勝手についてたくさんのご意見をいただきました。
こうしたご意見には「これはWebのままでは解決が難しいのではないか」というものも多く、体験改善の難しさが目立ってきたのです。

プレスが出ている通り弊社は調達も行い、今ならモバイルのエンジニアも増やせるぞ、というタイミングになったことも相まって、2023年のはじめ頃からモバイルエンジニアの採用を強化と、アプリの脱ガワアプリ化を目指す動きが始まりました。

layerx.connpass.com


検討した技術スタック

採用でエンジニアを増やせそう、と言っても際限なしに増やせるわけではありません。
10人増やそうったって、予算上限もありますし、コミュニケーションコストも上がるし、第一にオンボーディングの時点でキャパオーバーが目に見えています。

当面は1,2人程度を採用することを目指して、それでも爆速でリリースを迎えてメンテナンスもできる、そんな技術スタックを探すことになりました。

要望と要件

莫大な機能を持っているバクラク申請・経費精算のアプリ用再実装を、2-3人チームで、発足から3-6ヶ月で完遂できる。

そんな要望で技術スタックを探します。
3-6ヶ月でリリース?!んな無茶な。しかしそれをなんとかする方法を探すのがバクラクの爆速開発。

公開できるものに限っていますが、主にネックとなるであろう技術要件は以下でした。

  1. UIや申請フォームの複雑なバリデーションロジックをiOS/Androidで共用できる
  2. PDFをインライン表示できる
  3. 将来の拡張性のため、iOSのApp ExtensionやAndroidのWidgetを実装できる

それぞれをちょっとだけ解説します。

バクラク申請・経費精算はお客様の企業様の業務に合わせて、申請フォームを企業様内で定義できます。
例えば「初めて使用するクラウドサービスは申請しないと使えない。扱う情報がクラスAの場合はサービスのURLのみで良いが、クラスBの場合はプライバシーポリシーのページURLも記載すること」という規定があったとします。
この場合、フォームは以下のように構成します。

  • 扱う情報の区分
    • ラジオボタンで選択
      • 選択肢は「クラスA」「クラスB」
    • 常に入力必須
  • サービスのURL
    • テキスト入力欄
    • 常に入力必須
  • プライバシーポリシーページのURL
    • テキスト入力欄
    • 扱う情報の区分が「クラスB」のときのみ入力必須
「クラスA」「クラスB」を切り替えると、他の入力項目が入力必須になったりそうじゃなくなったりする例

他の入力項目の値によってバリデーション条件が変化するのがわかります。
条件に合わせて必須か否かが自動的に切り替わるため、従業員の方が「この場合はどれが必須なんだっけ」と迷わずに済み、申請のハードルが低いフォームが出来上がるのです。
そして、実際にはもっと複雑な組み合わせを作ることが可能です。

もちろん、アプリでもこれを実現しなければなりません。
これを2OS分実装してメンテナンスもする、というのは品質確認にかかるコストが莫大になります。開発中の簡単な動作検証も一苦労です。
この手間は可能な限り避けたいものとして、チームに認識されていました。

また、バクラク申請・経費精算はAI-OCRによる領収書などの自動読み取り機能があります。
カメラで撮影したレシートをアップロードすると、数秒で金額欄や取引先名を埋めてくれる優れものです!
どの部分を読み取ってフォームを埋めたのかは画像上にマーカーを表示することで示しているので、もし読み取り間違いがあったときも「どう間違ったのか」を説明でき、納得感の高い体験になっています。
このマーカー付き画像をPDFとして作成しているため、快い体験のためにはPDFの表示ができることが必須なのでした。

ランチで行ったお店のレシートもこの通り!レシートのどこを読んだのかがわかります


そして、せっかくネイティブアプリにするのです。
アプリが起動していないときも含めた体験も追求したい!
そうすると、ホーム画面や共有画面など、アプリ外の画面についても開発したいケースが出てくるでしょう。
そうしたときに「Swift/Kotlinじゃないから諦めないと…」と言いたくない!
ちゃんとそうした機能を開発できる余地を残しておきたいのです。

https://developer.apple.com/jp/app-extensions/


これらの要件を満たしながら高速に開発する必要があるため、複数OSの機能をワンソースで実現できるようなクロスプラットフォームフレームワークをメインに、複数のスタックを検討しました。

ネイティブ開発(iOSはSwift + SwiftUI、AndroidはKotlin + Jetpack Compose)

現在のWebViewアプリはiOSとAndroidを別々に作ってあります。
そこにそれぞれのOSで主流なUIフレームワークを乗せるという、正当進化の方策です。

OSのSDKを直接使用できるため、各OSが提供する機能をフルに活用できるという、ある意味で当たり前な、しかし最強のメリットがあります。
PDFの描画もiOS/Android共、SDKでサポートされていますし、使いやすくラップしたライブラリも公開されています。

コードベースについては、認証や計測など一部の既存のものは活かすことができますが、バリデーションロジックの共用はできません。
バクラク申請・経費精算の申請フォームは、利用者様の設定によって様々なバリデーションを組むことができます。
柔軟性が求められ、かつ複雑なこのバリデーションを二重実装、いや、現在のWebの実装も含めると三重実装をすることは、実装や品質保証の面で、今後のメンテナンスコストを劇的に増大させる可能性がありました。

Kotlin Multiplatform

www.jetbrains.com

日本国内でも複数のIT企業が採用しているフレームワークです。
ロジックはKotlinで記述します。Androidで大人気の言語なので、Kotlin向けの資産がすでにたくさんあるのも魅力。

最近はCompose for iOSのalpha版もでてきてUIのコード共通化の機運も高まってきましたが、まだアルファリリースとのことで、UIについてはiOSとAndroidで別々に記述する必要あり、と判断していました。

UIについては別々に記述するとなると、iOSやAndroidのライブラリを自由に使うことができます。もちろん、メンテナンスコストは両OS分発生しますが…

AndroidのWidgetはもちろん実現可能。iOSのApp Extensionも動作することをchocoさんに確かめて頂いたので、拡張性についても問題なさそうでした。

Flutter

flutter.dev


私が前職で使用していた技術でもあり、日本でも勢いがあるクロスプラットフォーム技術です。
ロジックをOSをまたいで使用できるというクロスプラットフォームフレームワークに一般的なメリットに加え、フレームワークが独自に画面を描画するという特徴があります。
これにより、バリデーションロジックだけでなくUIもワンソースで実現できるという大きなメリットがあります。


PDFの表示に関しても、プラグインがいくつか公開されている他、それらが要件を満たさなくても最悪PlatformViewを使用することでiOS/AndroidのPDF表示ライブラリを使用することで解決できます。

App ExtensionやWidgetはメモリや動作時間の制限があるため、ランタイム上で動かすDartで書くことは難しいとされています。
しかし、 @_chocoyama さんに実験していただいたところ、UIをSwiftUIなどOSネイティブな要素で構成しつつ、Dart Engine上で動いているDartのAPIを呼び出すのであれば実機でもメモリの制限内で動作させられることがわかりました。

また、 id:kikuchy がFlutterKaigiの運営でもあるため、採用市場に顔が利く、という点も若干プラスに働いた気がします。気のせいかも知れない。

Flutter採用の決め手は

ここまでで星取表は以下のとおりです。

ネイティブ実装 KMM Flutter
コードの共用 🔺
PDF表示 🔺
アプリ外機能実装

FlutterかKMMかで悩んでいたのですが、最終的にはUIを含めたコードの共用ができることを重く受け止め、Flutterを採用することにしました。

UIのコードを別々にしてしまうと、たとえロジックが共通だったとしても、UIレイヤーで生じるバグもありえますし、つなぎ込みでミスが発生する可能性があります。
結局、品質を確認する段階でのコストが増大するため、これを避ける決定をした、という形です。

Flutterを採用してどうだった?

UIの共通化が、当初の目論見通りの効力を発揮しています。
バクラク申請・経費精算は状態数がとても多いのですが、基本的にiOSシミュレーターで表示を確認しながら開発を進められています。OS固有の機能が絡む箇所は多くはないため、Androidでの表示と動作確認は適宜行うだけで済んでいます。

どのUIが開発されたのか・どんな見た目なのかをWidgetbookで共有したり、Atomic Designに沿って分類していたWidgetを別の方法で分類したりと、紆余曲折はあるものの、現在のチームが開発しやすく、メンテンナンスし易い方法を探しながら進んでいます。

新生バクラク申請・経費精算は絶賛開発中です!
俺たちの戦いはこれからだ!!

これから

リリースを迎えたら、ちゃんとしたアプリだからこそできることも追求したいと思っています。
日本の企業内の事務作業をどれだけ意識せずにできるようにするか、そこでアプリだからこそできることを実現してお客様に喜んでもらいたいですね!

もしFlutterで日本の事務作業をバクラクにすることに興味があれば、こちらのOpendoorからお声掛けください。お話しましょう!
jobs.layerx.co.jp



また、LayerXではCasual Nightという取り組みを始めました!
LayerXのメンバーと、プロダクトやチーム、エンジニアリングについてゆる〜くお話しながら色々な飲み物や食べ物を楽しむイベントです。

第3回のCola Nightは私も参加します!
何を隠そう、LayerXのコーラ部部員なのです。いろんなクラフトコーラを揃えておりますのでぜひ飲みにいらしてください!

jobs.layerx.co.jp

【特別対談】 Flutterエンジニアの今オレ x iOSエンジニアの過去オレ

こんにちは。バクラク申請・経費精算チームでモバイルエンジニアをしている @_chocoyama です。社内のラジオ好きコミュニティに属しているのですが、自分の推し番組を紹介したところ誰にも刺さらず、コミュニティに属しているのにソロ活動している今日このごろです。

この記事はLayerXテックアドカレ2023の29日目の記事です、前回は Tomoaki さんが「バクラクのAI-OCRを支える性能モニタリングの仕組み #LayerXテックアドカレ - LayerX エンジニアブログ」を書いてくれました。 本日の記事では、Flutterアプリを開発している現在の私(以降、今オレ)と、iOSネイティブアプリを開発をしていた過去の私(以降、過去オレ)が対談した内容となっています。

Flutterに対してふわっとしたイメージしかないネイティブアプリエンジニアの皆さんの参考になると幸いです。

ご挨拶

過去オレ:こんにちは、chocoyamaです。本日はどうぞよろしくお願いします。

今オレ:こんにちは、chocoyamaです。本日はどうぞよろしくお願いします。
過去オレさんはFlutterの解像度が低い部分も多いと思いますので、今日は何でも聞いてください。

過去オレ:ありがとうございます、それでは自己紹介から。私は2023年6月現在、iOSのネイティブアプリエンジニアをしております。UIはSwiftUIで作っており、Flutterはちょこちょこ触ったことがある程度です。

今オレ:私の方も簡単に。現在はバクラク申請・経費精算のモバイルアプリ開発をFlutterで進めております。現行アプリがWebViewのガワアプリのため、ほぼフルスクラッチで書いている状態でまだ未リリースです。

なぜFlutterにしたのか

過去オレ:早速ですが、なぜFlutterを採用したのですか?

今オレ:現状のチーム状況やプロダクトの複雑性を考慮して選定しました。明日チームメイトの @kikuchy さんから関連記事が出る予定なので、是非そちらも参考にしてみてください。

過去オレ:気になります。今オレさんの世界では明日記事が読めるのに、自分は待たないといけないの辛いです。

今オレ:たしかに過去オレさんが記事を読めるのは半年後ぐらいですね(笑)。楽しみに待っていてください!

Flutter開発の所感

過去オレ:実際に開発を始めてみて、どんな使い心地ですか?

今オレ:コードを書き始めたのは8月中旬ごろなのですが、とても生産性が高いと感じています。開発中のプロダクトは複雑性が高く必要な実装量も膨大であるため、複数プラットフォームの実装を1ソースで行えるメリットは非常に大きいですね。

過去オレ:言語がDartである点やフレームワーク・エディタに至るまでiOS開発とは環境が大きく異なると思いますが、その辺りは問題なかったですか?

今オレ:はい、大きな問題にはなりませんでした。SwiftUIとFlutterは宣言的UIで実装するという根本の部分が同じであるため、「どう作るか」の設計思考はそのまま使えています。SwiftUIに限らずJetpack ComposeやReact, Vueの経験者であれば、比較的学習コストが低いのではないでしょうか。

アーキテクチャ

過去オレ:iOSアプリを作る時はTCAやMVVMあたりが多い気がしますが、Flutterだとどんなアーキテクチャで開発していますか?

今オレ:イメージ的には、「MVVM」と「グローバルStateを扱うReducer的な仕組み」を組み合わせている感じでしょうか。それぞれflutter_hooksRiverpodという技術を駆使して実装しています。

過去オレ:どういった使い分けをしているのですか?

今オレ:flutter_hooksは「UI単体のローカルStateを扱う場合」に利用し、Riverpodは「複数のUIを跨ぐグローバルなStateを扱う場合」に利用しています。詳細な解説は割愛しますが、以下のようなイメージですね。

状態管理の構成

(flutter_hooksのカスタムフックのサンプルコード)

/// SomePage単体でのみ利用するローカルStateの定義
typedef SomePageState = ({
  int count,
});

/// SomePage単体で発生する状態変化イベントの定義
typedef SomePageAction = ({
  VoidCallback onTapIncrement,
  VoidCallback onTapDecrement,
});

/// flutter_hooksの機能を活用して状態の保持や変更を行う
({SomePageState state, SomePageAction action}) useSomePage() {
  final count = useState(0);
  return (
    state: (count: count.value),
    action: (
      onTapIncrement: useCallback(
        () => count.value += 1,
        const [],
      ),
      onTapDecrement: useCallback(
        () => count.value -= 1,
        const [],
      ),
    )
  );
}

/// 定義したカスタムフックを呼び出す
class SomePage extends HookWidget {
  const SomePage({super.key});

  @override
  Widget build(BuildContext context) {
    final (:state, :action) = useSomePage();

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text("count: ${state.count}"),
            ElevatedButton(
              onPressed: action.onTapIncrement,
              child: const Text("increment"),
            ),
            ElevatedButton(
              onPressed: action.onTapDecrement,
              child: const Text("decrement"),
            ),
          ],
        ),
      ),
    );
  }
}

(Riverpodを扱ったのサンプルコード)

/// グローバルで扱うStateの定義とProviderの作成
///(状態変化の方向を1方向にするため、直接Stateにはアクセスできないようにしている)
@freezed
class UserState with _$UserState {
  factory UserState({
    @Default(User()) User user,
  }) = _UserState;
}

final _userStateProvider =
    AutoDisposeStateProvider<UserState>((ref) => UserState());

/// ReadonlyなStateを参照するためのProvider定義
@riverpod
UserState userReader(UserReaderRef ref) => ref.watch(_userStateProvider);

/// Stateを変更するための純粋関数
void setUserModifier(
  Reader read, {
  required User user,
}) =>
    read(_userStateProvider.notifier).update(
      (state) => state.copyWith(user: user),
    );

/// 定義したProviderを呼び出す
class SomePage extends ConsumerWidget {
  const SomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(userReaderProvider);
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text("name: ${state.user.name}"),
            ElevatedButton(
              onPressed: () =>
                  setUserModifier(ref.read, user: const User(name: "updated")),
              child: const Text("update"),
            ),
          ],
        ),
      ),
    );
  }
}

今オレ:ちなみに、UIアーキテクチャについてはチームメンバーの yohei さんが書いた FlutterアプリにおけるUI Component Architecture #LayerXテックアドカレ - LayerX エンジニアブログ も参考にしてみてください。

過去オレ:また読めるの半年後じゃないですか…。

SwiftとDart

過去オレ:言語についても聞かせてください。Swiftは最近でもConcurrencyやMacroの追加などもあり進化し続けています。ここからDartにスイッチすると機能落ちしませんか?

今オレ:正直、Swiftほど高機能ではないとは思っています。ただ、今年リリースされたDart3では表現の幅が大きく広がっているので、かなり開発はしやすくなっています。

過去オレ:そうなんですね、気に入っている機能はありますか?

今オレ:Records型, Sealed class, Pattern Matchの追加などは気に入っています。Swiftでは「enumのcaseごとに特有の値を持たせ、switchで網羅チェックをする」という対応をよくしていたのですが、Dartでもこれと同等のことが可能になっています。

(Swiftでのサンプルコード)

struct SomeImmutableValue {
    let intValue: Int
    let stringValue: String
}

enum SomeEnum {
    case a(SomeImmutableValue)
    case b
    case c
}

func main() {
    let someImmutableValue = SomeImmutableValue(intValue: 0, stringValue: "")
    let someInstanceA = SomeEnum.a(someImmutableValue)
    let hoge: SomeImmutableValue? = switch someInstanceA {
    case .a(let value): value
    case .b, .c: nil
    }
    print(hoge)
}

(Dart3を活用したサンプルコード)

/// 値型を簡潔に定義できる
typedef SomeImmutableValue = ({
  int intValue,
  String stringValue,
});

/// sealed classの定義
sealed class SomeSealedClass {
  const SomeSealedClass._();
}

/// sealed classをimplementsしたクラスを定義
class SomeClassA implements SomeSealedClass {
  const SomeClassA(this.value);
  final SomeImmutableValue value;
}
class SomeClassB implements SomeSealedClass {}
class SomeClassC implements SomeSealedClass {}

void main() {
  // インスタンスの生成
  final someImmutableValue = (intValue: 0, stringValue: "hoge");
  final someInstanceA = SomeClassA(someImmutableValue);

  // selaed classをswitchすると対象のクラスを全て網羅しないとコンパイルエラーになる
  // (Swiftでswitch enumするときのアレ!)
  // case文でパターンマッチさせて個別の処理をすることもできるし、下記のように値を直接返却することも可能
  final value = switch (someInstanceA) {
      SomeClassA(value: final value) => value,
      SomeClassB() || SomeClassC() => null,
  };
  print(value); // (valueA: 0, valueB: hoge)
}

過去オレ:Dartも日々進化しているんですね。

今オレ:そうですね。 Announcing Dart 3. 100% sound null safety. Records… | by Michael Thomsen | Dart | Medium は5月に発表されている記事なので、過去オレさんも参考にしてみてください。

過去オレ:この記事は読める!

Flutterの辛いところは?

過去オレ:では別の話題で、Flutter開発で辛いと感じる部分はありますか?

今オレ:コード自動生成への依存が多いのが個人的には辛いですね。Diffが大きくなったりbuild_runnerのwatchを実行しておく面倒さは感じます。あと、実装コードがSwiftUIと比べるとゴチャつくというのもありますね。

過去オレ:SwiftUIではXcode Previewsによって、アプリを実行せずに対象ファイルを開くだけで表示確認ができる便利な機能もありますよね。Flutterだとこういった機能はないですか?

https://developer.apple.com/jp/xcode/swiftui/

今オレ:残念ながらないですね。開発時はHot Reloadにより効率的に実装を進められますが、ファイルから直接表示確認することはできません。

過去オレ:そこはSwiftUIの方が使いやすそうですね。

今オレ:そうですね、ただ代替手段としてWidgetbookというツールを導入しました。これは実装済みWidgetのカタログアプリを生成できるツールで、Web開発者向けにはStorybookにあたるものといえばわかりやすいでしょうか。

過去オレ:便利そうですね、WidgetのUIを確認したくなったら自動生成されたアプリを起動すればいいと。

今オレ:そうなんです。ただ、確認したい時に都度アプリをビルドするのはめんどくさいですよね。そのため、mainブランチマージ時に自動でGitHub Pagesにホスティングするようにしました。

Widgetbookを使って動作させているWebアプリ

過去オレ:そんなことができるんですか!

今オレ:はい、FlutterはWeb用に動かすこともできるので、こういった手段が取れちゃいます。

過去オレ:共通UIコンポーネントを確認したいときに、Webからさっと触れるの素晴らしいですね。

OS最適化された体験は作れる?

過去オレ:最後にもう一点、FlutterはGoogleがメンテナンスしているのでiOSに最適化された体験を作りづらい印象があるのですがどうですか?

今オレ:ネイティブ実装と比べてしまうと作りづらいとは思います。デザインシステムはMaterial Designベースですしね。Cupertinoを活用してiOSらしいUIにもできますが、開発・デザイン両面でコストは増加するので、チームでは原則Material Designベースで作ることを決めていました。

過去オレ:パフォーマンス面はどうですか?

今オレ:最近はiOSの120Hz対応なども入ったり、ネイティブ実装と遜色なくなってはいます。ただ、Flutter特有のパフォーマンスを落としてしまう実装はあるので注意は必要です。

過去オレ:ネイティブ機能との連携はどうでしょうか?

今オレ:多くのケースでは、pub.devを検索すればプラットフォームを抽象化してくれるパッケージが見つかります。最悪自作する必要はありますが、MethodChannelとpegeonを使って型安全な連携もできるので、大きな懸念要因にはならないかなと考えています。

過去オレ:AppExtensionを作る場合はプロセスが本体アプリと切り離されるので、最悪Flutterで書いたコードを二重実装することになったりしませんか?

今オレ:その点は私も懸念していました。調査したところ、FlutterEngineをAppExtension&本体アプリ上で直接動作させ、Dartの実装をSwift/Kotlinから呼び出せることは確認できています。

過去オレ:そんなことも!つまりFlutter側のAPI通信やビジネスロジックなどを、Swiftコードから呼び出せるんですね。

今オレ:はい、可能でした。こちらについては別途アウトプットしたいなと思っています。

おわりに

過去オレ:本日はお話ありがとうございました。ぼんやり感じていたFlutterへの不安感が払拭された気がします。

今オレ:ネイティブアプリをメインに開発していると、クロスプラットフォーム技術の採用に慎重になっちゃいますよね。しかし今のところは大きく困っていることは無く、むしろとても良い開発者体験のもと進められています。同じように不安感がある方や、LayerXのプロダクト&開発に興味がある方などいらっしゃれば、カジュアル面談も待っていますので是非お声がけください。

jobs.layerx.co.jp

LayerXのモバイルアプリ開発について話しましょう!


過去オレ:ありがとうございます。同僚にも話してみますが、参加できるのが半年後ぐらいなのでちょっと先すぎますね(笑)

今オレ:そうですね(笑)。未来で待ってます。


採用情報 → jobs.layerx.co.jp

カジュアルナイト → jobs.layerx.co.jp/casual-night

バクラクのAI-OCRを支える性能モニタリングの仕組み #LayerXテックアドカレ

こんにちは、希望あふれる優しいデジタル社会を、未来に残したいTomoakiです。

この記事は LayerXテックアドカレ28日目の記事です。前回は勤怠の苦労から全社員を救済したitkqさんによる勤怠をいい感じにする社内Slackアプリでした。次回は玉ねぎを愛し玉ねぎに愛されたおとこ、chocoyamaさんが担当します。

今回はバクラクのAI-OCR機能の性能モニタリングに仕組みについて紹介します。

バクラクのAI-OCR機能について

バクラクでは、請求書や領収書をはじめとする国税関係の書類にOCRを実行し、入力のサジェストを行うことで、お客様が書類の内容を手入力する手間を省略しています。

例えばこちらの領収書、日付、金額、支払先、登録番号を自動で読み取ってお客様にサジェストをしています。

先日買ったコーヒーのレシート

性能モニタリングで達成したいこと

性能モニタリングの意義としては現状を正確に把握し改善に繋げることです。具体的には以下の観点でモニタリングしています。

お客様の体験の把握

現状の性能がどの程度かを知ることは非常に重要です。現状のAI-OCRの性能を理解することは、お客様の体験を理解する大きな手がかりとなります。

読み取りに失敗すると、数十秒から場合によっては分単位での修正による時間のロスになる可能性があります。これは、月末月初で時間との戦いとなる経理の方々や、経費精算などの苦手な事務作業に立ち向かおうとする現場の方々にとって、大きなダメージです。

ここで重要なのは、単純な読み取り精度をモニタリングするのではなく、お客様の体験をモニタリングするということです。

後述しますが、単純な読み取り精度が低くても、お客様の体験は損なわれていないケースもありますし、その逆の場合もあります。

正しい優先順位で次の開発タスクを検討する上でも体験が損なわれているお客様をいち早く検出することが重要です。

過去モデルの性能との比較

バクラクで使用されているAI-OCRの機械学習モデルは定期的に再学習されていますが、新しいモデルのリリースには常にリスクが伴います。

バクラクのAI-OCRは全てのお客様に同じモデルを利用いただいているため、全体の性能は良くても、一部のお客様だけ前のモデルから性能が落ちてしまうという事象は一定発生してしまいます。

リリース前の評価時にお客様ごとに大きく体験が損なわれないかの確認はしていますが、どうしても予想できない部分もあるので、常に過去のモデルと比較してモニタリングしておくことは開発で次のアクションを考える上で重要です。

時系列的な精度の推移

データの傾向は常に変化するためリリース直後は精度が良くても、データの変化に対応できず精度が一部のお客様に限って悪化する可能性があります。

また、法改正によって書類に記載すべき情報が増えた時や、年号が変わったときなども書類の内容に変化が発生しAI-OCRの性能に予期せぬ影響を与えることがあります。

このような変化を検知するために、精度の時系列的な推移を確認しておくことは重要です。

バグの発見

OCRのロジックは極めて複雑です。機械学習モデルが日付、金額、支払先など各項目の値を推論する処理だけでなく、その前後の様々な処理が施されています。

前処理・後処理はリリース前に入念にテストをしますが、万が一バグがあった場合は早期発見して改善できるかが重要です。

例えば、精度が99%出ているようなお客様でも、残りの1%がバグのような挙動をしていたら体験としては非常に悪いです。(例えば色塗り箇所と違う場所をサジェストされているなど)

もちろん明確にエラーログが吐かれるようなバグの場合は、エラーログを監視していれば容易に検知できますが、例えば文字列をパースする処理に不備がありエラーにはならないが、読み取り結果には影響が出るようなバグはモニタリングを通じて検知するしかありません。

性能モニタリングに求められる要件

バクラクは2023年12月現在、導入社数が8000社を超えており、そのほとんどのお客様がOCRを利用してくださっています。

導入者数もさることながら、毎月アップロードされる書類の枚数も爆発的に増加しており、お客様によっては万単位の書類を毎月アップロードされるケースも少なくありません。

このような状況では、OCRの性能をモニタリングするのは大変です。当然ながら、すべての帳票を目視でチェックすることは不可能です。

リアルタイムで測定可能な定量的指標

リアルタイムでモニタリングできることは非常に重要です。何か問題が発生しても、それに気づくのが1週間後だった場合、モニタリングでそれに気づくよりも先に、お客様からの問い合わせが殺到することになるでしょう。

また、すべての帳票を目視でチェックするわけにはいかないので、指標はリアルタイム性と定量性を兼ね備えていることが望ましいです。

バクラクの性能モニタリングでは、「お客様が入力した値」と「OCRがサジェストした値」を比較した正解率を重要な指標としています。

全体の精度だけでなく、お客様ごとの精度

では、この正解率さえ毎日見ていれば良いのでしょうか?

バクラクは8000社以上のお客様が利用しており、お客様によっては万単位の書類を毎月アップロードされるため、全てのお客様横断の正解率も重要ですが、お客様一人一人の体験を評価するにはこれでは不十分です。

たとえば、月に10万枚の書類をアップロードするお客様の精度が99.9%で、月に100枚をアップロードするスタートアップのお客様の正解率が30%だとすると、全体の正解率は99.9%になり問題がないように見えますが、小規模なお客様の体験の悪さは検知できません。

したがって、全体の精度だけでなく、お客様ごとの精度を追跡することが重要です。

帳票のプレビューができること

「お客様が入力した値」と「OCRがサジェストした値」の正解率という指標は信頼できるものでしょうか?

OCRによるサジェストの値と入力値が異なると、何らかの修正が発生していることを示します。しかし、それだけではお客様の体験をモニタリングするのには十分ではありません。

お客様はなんらかしらの事情で書類に書いてない値を意図的に入力する可能性があります。 例えば、業務委託の方からの請求書の請求額から源泉徴収税を引いたり、書類日付が明記されていないファイルに対して 後に検索できるようにするために何かしらの日付を入力している場合などそのパターンは色々です。

この場合、正解率は定量的には下がって見えますが、お客様の体験は損なわれているわけではありません。

定量的な精度(=正解率など)はお客様の体験を理解するための参考値として有用ですが、あくまでやりたいのは「お客様の体験」のモニタリングです。

したがって、「お客様の体験」を理解しAI-OCRの性能を把握するためには、実際の書類を確認し、分析する必要があります。そのためモニタリングの対象は帳票の画像データも含むことが重要です。

帳票単位でのデバッグに必要な情報があること

読み取れない帳票がある場合、その原因を早急に調査できることが重要です。

例えば、読み取れない帳票がある場合、文字認識がうまくいっていないのか、項目推定の推論がうまくいっていないのか、あるいは前処理や後処理に原因があるのか、原因次第で必要なアクションが変わることがあります。

毎日のモニタリングでそこまでの分析をやる...?と思う方もいるかもしれないですが、起きている事象を把握して素早く改善に繋げていくためには必要なことだと思っいます。

以上のことをまとめるとバクラクのAI-OCRの性能モニタリングの要件は以下になります

  • リアルタイムで性能をモニタリングできる指標があること
  • お客様全体とお客様ごと、両方の精度モニタリングできること
  • 定量的な指標に加えて、具体的な書類の情報も一緒に確認できること
  • ファイルのプレビューだけなく、迅速にデバッグするための情報が整っていること

性能モニタリングの具体的な方法

モニタリングは以下の方法で実施しています。

  • モニタリング対象のデータ

    • 前日分のデータと前週分のデータ
    • 対象は全てのお客様(8000+)であり、対象はOCRを利用する全てのサービス(バクラク請求書・申請・経費精算・電子帳簿保存)
  • モニタリングで実施していること

    • 毎日朝会にて15分ほど、機械学習チーム全員参加でモニタリングの時間を設ける
    • 正解率をお客様ごとに集計し、正解率が特定の閾値を下回るお客様を抽出(Looker Studio)
    • 抽出したお客様の読み取りに失敗している帳票を確認し、不正解だった原因の分析
    • 改善タスクの起票
      • モデルの改善
      • 前処理・後処理の改善
      • お客様へサービスの設定の案内
      • モデルの切り替え検討
      • etc…

工夫した点

内製しているアノテーション基盤の活用

Looker Studioで構築したダッシュボードを活用しつつ、読み取りが失敗したファイルに関しては、内製しているアノテーション基盤へのリンクをダッシュボード内に表示することで瞬時にファイルの分析を可能にしました。

アノテーション画面

このアノテーション基盤では、ワンクリックでファイルをプレビューでき、OCRの前処理や後処理で作成された中間生成ファイルのプレビューも可能です。

以前は都度ファイルのURLを取得してAWSにアクセスする必要があり非常に手間になっていましたが、大幅な作業時間削減につながりました。

アノテーション作業に特化する場合、アノテーションツールを自作する必要はないかもしれません。しかし、このような分析作業でも利用できることを考えると、自前でアノテーションツールを作成する意義は大いにあると思います。

dbtの活用

全プロダクトに散らばったデータを一つのダッシュボードに集約するとクエリは相当複雑になります。 また、集約したい期間などクエリは随時アップデートされる可能性があったり、ダッシュボードないで同じクエリを使い回したいケースも多いです。

このように可読性・保守性の高いモニタリングダッシュボードのためにdbtを活用してクエリを細分化しています。

例えば、複数のサービスでAI-OCRを実行しているため、お客様の入力値は各サービスのデータベースに散らばっています。しかし、サービス単位で正解率を集計した上で、ダッシュボードでは全てのサービス横断で問題のありそうなお客様を抽出するために、dbtを活用して階層的にデータを作成しています。

モニタリングのナレッジの蓄積

数ヶ月この運用で続けた結果、閾値を下回るお客様でも、実際に帳票を確認してみると体験はそこまで損なわれていないケースがかなり多いことがわかりました。

例えば、書類に特定の項目の値が書いてないケースなどがあります。このようなケースでは、OCRとしては情報が記載されていないので特段何も読み取れないことが望ましいですが、お客様は何らかの値を入力しているので、定量的な精度は低く出てしまいすが、実際は体験はさほど損なわれてないはずです。

そして、お客様の帳票の傾向はすぐにコロコロ変わるものではないので、頻繁に同じお客様が閾値を下回り分析対象に入ってくるということが発生しました。

記憶力のよい人が、前回このお客様はこういう傾向があったと覚えていてくれれば良いのですが、人間は忘れる生き物なので、一度分析したお客様のファイルを再度分析するという事態が頻繁に起こってしまいます。

また、新メンバーが入ってきた時に過去のモニタリングで得られた知見が人間の記憶頼りだとうまくナレッジの共有ができません。

モニタリング時にお客様の傾向を蓄積

この課題を解決するためにアノテーション基盤にお客様ごとのモニタリングのログを蓄積できるようにしました。

同じお客様がモニタリングの対象になったとしても、お客様単位でログを蓄積しているので、「あ、このお客様は前回はこの理由で閾値を下回ったのだ」と知ることができ、モニタリングの効率が向上しました。

担当者による事前分析

朝会におけるモニタリングは目標は15分、長くても30分で終わらせたいところでしたが、30分を超えてしまう日が結構ありました。また、開発時間の確保や朝の長い会議による疲労が無視できない負担となっていました。

モニタリングは週に一回や月に一回にしてしまえば解決できると言えばできるのですが、お客様の体験にこだわる・爆速でアップデートするを体現する上で毎日deep diveしたモニタリングは重要な要素なので諦めたくはありません。

そのため、週替わりで担当者を決め、事前に閾値を下回る要注意のお客様のリストアップと読み取りエラーの分析を終え、朝の会議は共有とアクションのディスカッションの場にすることで、クオリティを維持したまま、会議時間の削減を図りました。

モニタリングの成果

毎日何かしらの改善点を発見

粒度は大小様々ですが、毎日の深掘りしたモニタリングにより、多くの発見があります。 バックログのタスクのほとんどはモニタリングの時間で作られていると言っても過言でもありません。

軽微な前処理・後処理のバグなどはすぐに修正してリリースして改善のサイクルを回しています。

課題に関して毎日同期的に話すことで、課題に対する理解と解像度が深まる

読み取れてない帳票があるときに、要因が明確であることは多くありません。 必然的に「なぜ読み取れてないのか」、「改善するにはどういうアプローチがあるか」といったディスカッションが自然と行われます。

これは、課題に対する理解と解像度を深めるという点で非常に有益であり、新メンバーがキャッチアップする上でも大きな意義があると思います。

エンジニアのドメイン知識の獲得

世の中には多様な書体、書式、言語、レイアウトの書類が存在し、100社あればそれぞれ独自の書類があります。私も3年近く様々な帳票を見てきましたが、毎日新たな発見があります。

多くの帳票を見ることは、エンジニアがドメインの理解を深める大きな助けとなります。エンジニアのドメイン知識の獲得は、最終的に改善のサイクルを早めることにつながり、非常に重要だと思います。

今後の課題

アノテーション対象の作成までの自動化

モニタリングで性能の低下を検知した場合、すぐにアノテーションを行い、改善につなげる仕組みはまだまだ整っていません。

バクラクには専門のアノテーションチームがありますが、アノテーションの対象は人間の判断でピックアップしています。

ピックアップの作業は大きな負担ですし、何より選定の判断基準が最終的には属人的になってしまうため自動化をしていきたいと考えています。

また、どのようなデータを優先的にアノテーションすれば性能向上に効率的に寄与できるかの分析もまだまだできていない部分が大きく課題に感じています。

さらなるデータ増加に備えたモニタリングの仕組み

データが爆発的に増加しているため、現状のモニタリングが1年後も同じ運用で回るとは思っていません。

今は毎日15分程度でモニタリングは完結していますが、データが10倍、100倍になっていったときどのようにしてモニタリングしていいかは悩ましいです。

最後に

今回はバクラクのAI-OCRの性能モニタリングについて紹介しました!

モニタリングはお客様の体験にこだわる・爆速でアップデートするを体現する上で非常に重要ですが、課題もいろいろあります。

LayerXではバクラクな体験を届けワクワクする働き方を提供したいエンジニアを大募集しています!

気になった方はぜひカジュアル面談しましょう!

jobs.layerx.co.jp

プロダクト開発チームとDevOpsチームでのプロダクト課題改善の取り組み

こんにちは!バクラク事業部 Platform Engineering 部 DevOps チームの id:sadayoshi_tada (@tada_infra)です。趣味でボディビルディングの大会に出ているのですが、フィジークという部門で今年初めて入賞することができました。来年は更に良い成績を目指してデカい男になりたいです 💪

この記事は SRE Advent Calendar 2023 5日目の記事です。4日目は@egmcさんのIaC、あるいはインフラ抽象化レイヤー導入時に考えたらいいんじゃないかと思うことを雑多に書くという記事でした。本記事では、私とプロダクト開発チームで行った、プロダクトの課題改善の取り組みについてお話を書いていきます。

qiita.com

DevOpsチームのプロダクト開発チームとの関わり方

本題に入る前に DevOps チームとプロダクト開発チームの関わり方を紹介します。バクラク事業部では支出管理をなめらかに一本化するSaaS「バクラク」を開発しており、2021年1月に最初のプロダクト「バクラク請求書受取・仕訳」を提供開始以降、「バクラク申請」、「バクラク電子帳簿保存」、「バクラク経費精算」、「バクラクビジネスカード」、「バクラク請求書発行」など、合計6プロダクトを提供しています。

bakuraku.jp

これまではアドホックに課題を一緒に解決する動きをしてきたのですが、サービスがスケールしていくに伴ってそれぞれのプロダクトで様々な課題が出てきました。 プロダクト開発チームとのコミュニケーションを密にして連携を深めていくため、プロダクト毎に担当者を置き各プロダクトごとの課題に取り組むようにしていくことにしました(Embeded SRE の動きのイメージです)。

バクラク請求書受取・仕訳チームとの取り組み紹介

私はバクラク請求書受取・仕訳チームと共に課題にあたっており、この記事では課題の中でもシステムのレイテンシー改善を一緒に対応した時の取り組みを紹介します。

CUJ を使った課題の精査

まず、プロダクト開発チームと課題についての精査をしていきました。課題に対してどういう方針であたっていくかの1つの指標として、Critical User Journey (以下、CUJ)を使って考えていくことを提案しました。というのもパフォーマンス課題改善を図る中でお客様の体験が変わる変化を起こせないと良くないと思い、お客様にとっての重要な体験にフォーカスした取り組みを進められるよう CUJ を開発チームに進言して方針として採用されました。

CUJ の検討においては開発チームとPdMを交えて話し合いを行ったのですが、下記の記事を参照させていただきつつ事前にプロダクトの機能の中でお客様にとって重要な体験をリストアップし、選別していきました。

medium.com

プロダクト開発チームと PdM と一緒に重要な体験を列挙した際のイメージ

プロダクトの機能ごとに重要な体験を列挙し、重点的に課題改善を取り組むものを赤丸のオブジェクトで投票して選別しました。

上記の話し合いを経て日々のお客様の業務からバクラク請求書受取・仕訳がどのように使われていて、この機能の体験が悪いとバクラクを届けきれてない、といったコミュニケーションができたことが良かったです。そして、付箋紙でリストアップ機能の中から参加者が重要と思う体験の機能を選び、レイテンシー改善を図っていくことにしました。

改善アクション前の目標値検討

実際の改善活動を行う前に目標値検討をプロダクト開発チームと行い、フロントエンドとバックエンドそれぞれで目標値を掲げて対応していくことにしました。

フロントエンドの目標検討イメージ

フロントエンドでは直近のメトリクス推移から目標値を99%タイルで特定パスのLCPを4秒台にすることを目指していくことにしました。

バックエンドの目標検討イメージ

バックエンドでは直近のメトリクス推移から目標値を99%タイルでレイテンシーを3秒以内に収めることを目指すことにしました。

改善アクションと実施後の振り返り

目標値を定めたので、具体の改善アクションに移していきました。改善のアクションがどのリリースで反映されているか、反映される前後のレイテンシーの状況を記録して定期的に振返りできるようにし、次の改善計画に使っています。

振り返りのイメージ

改善後の結果サマリ

最後に直近行った、フロントエンドの改善とバックエンドの改善の結果について簡単にまとめます。フロントエンドは特定パスのLCPを短くしていく動きをしていたのですが、 99%タイルで改善前39.9秒かかっていたのが改善後21.06秒になり、10秒以上の短縮につながりました。

改善前

改善後

また、バックエンドの方はリリース前後のレイテンシーを比較で見た時(赤の点線がリリース前で青の実線がリリース後です)、最大約1秒ほどのレイテンシー改善が見られました。

バックエンドリリース前後のレイテンシー比較

99%タイルで改善前3.91秒かかっていたのが、改善後2.66秒に改善しました。

加えてバックエンドの DB では Amazon Aurora MySQL 互換を使っているのですが、Aurora AutoScaling がお客様の業務でアクセスが集中するタイミングに必要なスケールアウトが間に合ってなくてレイテンシーが劣化してる動きを観測しました。そこで Schedule AutoScaling でスケールアウトの調整を行ってバックエンドのレイテンシー劣化を改善することができました。

まとめ

私とプロダクト開発チームで行った、プロダクトの課題改善に関するお話を書きました。引続き共に改善に取り組み、1つずつ積み上げてお客様へバクラクな体験を届けていきます。まだ取り組み中のこともあるため、それらは整えたらまた別の記事として書きたいと思います。

最後に

弊社では共に世の中をバクラクにしてくれる仲間を絶賛募集中です!もし気になることや話したいトピックなどがありましたらカジュアルにお話しましょう〜LayerX Casual Night というお酒を飲みながらカジュアル面談よりカジュアルにゆる~く話せるイベントも開催しておりますので、ぜひご参加ください🍻

jobs.layerx.co.jp

また、個別のカジュアル面談のページもありまして、最初は面談だけでOK等であれば下記のリンク先から応募いただけると幸いです!

jobs.layerx.co.jp

勤怠をいい感じにする社内Slackアプリ #LayerXテックアドカレ

バクラク事業部Platform Engineering部DevOpsチームの id:itkq です。CTO室という事業部横断のコーポレートエンジニアリング組織を兼務しています。早いもので今年も終わりが近づいてきました。Spotify 2023 Wrappedによると今年一番聴いたアーティストは結束バンドで、一番聴いた曲は『忘れてやらない』でした。

この記事は LayerXテックアドカレ 27日目の記事です。前回は yohei による FlutterアプリにおけるUI Component Architecture でした。次回は Tomoaki が担当します。今回は、勤怠関連の社内Slackアプリを開発して運用している話をします。

LayerXにおける勤怠と習慣

LayerXでは勤怠システムにAKASHIを採用しています。システム上の勤怠に加えて、次のような習慣があります。

  • 出勤時に統一のSlackチャンネル #corp_info で連絡する。出社 or リモート、途中で移動、早抜け、などの情報を書く
  • 毎日統一のSlackチャンネル #daily_report に日報を書く。基本フォーマットは「やったこと・やること・ひとこと」。退勤時に投稿する人が多いが投稿時間の縛りはない。基本的に経営メンバー含むすべての社員が投稿する

日報には「ひとこと」を書くことで面白さが生まれてコミュニケーションが発生したりします。社員数は増加し続けているため、これらのチャンネルにはそれなりの投稿がありますが、引き続きリスペクトしていきたい習慣だと思っています。

勤怠が難しいという現実

私の前職ではPCの操作ログから自動的に打刻されるシステムを使っていた 1 ため、自身の意志で打刻をするように脳を切り替える必要がありました。ただ現実は難しく、以下のようなことがたびたび起こっていました。

  • 打刻を忘れてしまう
    • リモート勤務が多いので何らかの記録(例えばWi-Fiの接続記録で自動的に打刻)に頼れない
  • 出勤連絡を投稿し忘れる
  • 日報を書き忘れる

ブラウザで毎日AKASHIのWebページを開くのが億劫になりつつあったことと、出勤連絡と日報を書く習慣から、Slack経由で何かできないかを考えていました。その頃、自分の入社前からSlackスラッシュコマンド経由で打刻できるシンプルなシステムが存在していたものの、あまり活用されていないということを聞きました。その理由も聞きつつ、労務チームとも要件を相談し、新規Slackアプリとして実装することにしました。

“slack-kintai” の紹介

現在開発・運用している slack-kintai というアプリの基本機能について紹介します。ホーム画面から、AKASHI API経由で打刻(出勤、退勤、休憩入、休憩戻)することができます。ここでは基本となる出勤と退勤のフローについて紹介します。勤怠と関連する習慣を一箇所で同時に行うことで、操作を忘れることを防止できます。

出勤

出勤ボタンから、打刻と同時に出勤連絡をポストします。"in wfh" はリモートの場合に使われるテンプレートみたいなものです。

ホーム画面

リモート出勤画面

出勤連絡

退勤

退勤ボタンから、打刻と同時に日報をポストできます。日報のテキストエディタには基本のフォーマットが埋まっており、書くことが少し楽になります。

退勤画面

日報の投稿

実装とインフラ

Slackアプリは bolt.js を使ってTypeScriptで実装しています。利用者本人としてSlack操作をする必要があったため、OAuthを利用しています。

運用負荷を可能な限り下げるため、AWSのサーバーレスサービスを使って構築しています。特に面白みはありませんが、詳細は以下です。

  • ホスティング: AWS App Runner
  • データベース: DynamoDB
  • 定期バッチ: EventBridge Scheduler + Step Functions + ECS RunTask (Fargate)

slack-kintaiの便利機能

基本機能以外の機能についても紹介します。

出勤報告と日報を別のチャンネル (e.g. チームチャンネル) にクロスポストするオプション

出勤連絡のクロスポスト

ステータス同期オプション。オフィス出勤では「🏢」リモート出勤では「🏡」絵文字を設定し、退勤時にクリアする

Slackステータスの自動更新

出勤と退勤のリマインダー。それぞれの少し前の時間に設定しておくと、通知バッチがつくので打刻漏れを減らせる

出勤リマインダー設定
出勤リマインダー通知

出勤報告の文字列をSlack検索することで、月ごとの出社日を算出。交通費精算で便利

統計情報として月のオフィス出社日を算出。全然オフィスに行っていないことが分かる

その他細かいもの

  • 打刻漏れなどの勤怠アラートがある場合リマインドする
  • 所定労働時間と実績、その差分の表示
  • 前回の日報を保存して呼び出せるように(やる → やった になりがちなので)

導入から半年経ってみて

現在は100人弱がslack-kintaiを利用しています。 人生で一番打刻がうまくいっている、と複数の声をいただくなど、定性的には一定の課題を解決しているといえます。個人的にも、リマインダーのおかげで打刻と出勤報告を忘れることがほぼ無くなりました。一方で定量的にどの程度良くなったかは計測できておらず、今後の課題です。 導入後も継続的に要望があり、要望数や妥当性を考慮して機能開発しています。また、アプリを利用しているデザイナーの方が、よければどうぞとシュッとアイコンを作ってくれたことが嬉しかったです。今後も引き続き勤怠をいい感じにできればと思っています。


  1. これはこれで本人は休憩しているつもりなのに休憩が記録されないなど難点もあった

FlutterアプリにおけるUI Component Architecture #LayerXテックアドカレ

こんにちは。バクラク申請・経費精算 ネイティブアプリエンジニアのyoheiです。

最近はこたけ正義感の逆転裁判プレイ動画を見ながら法律の勉強してます。好きなラジオは真空ジェシカのラジオ父ちゃんです。M-1も応援してます!

この記事はLayerXテックアドカレ2023の26日目の記事です。前回は 赤羽さん が「Go言語のORMであるGORMをv1からv2へのマイグレーションした話」を書いてくれました。27日目の記事 id:itkq さんより「勤怠をいい感じにする社内Slackアプリ #LayerXテックアドカレ - LayerX エンジニアブログ」ポストされました。一緒にご覧いただけたらと!

バクラク申請・経費申請では現在のモバイルアプリをFlutterでのリプレースを進めています。そのうえでチームとしてUIコンポーネント(Widget)をどの用に作っていくか設計(UI Component Architecture)を決め開発を進めています。

ここ最近、Atomic DesignからLayerX独自の設計に落ち着いたので、なぜAtomic Designから独自設計に移行したのかを紹介していきます。

(※Atomic Designについては色々なサイトで紹介しているので、今回は割愛させていただきます)

アプリで使用している技術

まず、Flutterアプリで利用している技術の一部を説明していきます。

  • API
    • GraphQL
  • 状態管理
    • Flutter hooks(1UIコンポーネント内の状態管理)
    • Riverpod (UIコンポーネントをまたぐ場合の状態管理)
  • Widgetカタログ

Atomic Designを採用した理由?

LayerXでは元々WebアプリでAtomic Designを採用していたため、それに倣ってやっていこう。molecules, Organismsなど分割していけばユースケースにハマり開発がしやすくなるのではと考え、Atomic Designを採用しました。

Atomic Designの構成

LayerXでのUIコンポーネントはAtoms, Molecules, Organisms, Parts, Templates, Pagesと6つの要素で構成しています。(Partsに関しては独自の要素になっています)

各要素を簡単に説明してきます。

Atoms

  • ページを構成するこれ以上分けられない最小構成要素
  • ドメインに依存しない

Molecules

  • 意味を持つ要素
  • Atomsを組み合わせて作成される
  • ドメインに依存しない

Organisms

  • サービスとして意味のある単体で機能する要素
  • Atoms, Molecules, Organismsを組み合わせて作成される
  • ドメインに依存する
  • Fragment colocationを定義はできるがAPI Callはできるだけしない(parts, pagesで行う)
  • ロジックを持つ

Templates

  • ページ全体の骨組み
  • Atoms, Molecules, Organismsを組み合わせて作成される
  • ドメインに依存する
  • ロジックは持たない
  • 1つの Parts or Page に1つのtemplateが存在する

Parts

  • Page未満、Organisms以上のUIコンポーネント
  • ドメインに依存する
  • ロジックを持つ
  • データ取得を行う

Pages

  • UIの最終形態
  • route定義としてでてくる
  • ドメインに依存する
  • ロジックを持つ
  • データ取得を行う

まとめると以下ようになります。

要素 依存可能UIコンポーネント ドメイン依存 Data取得(API実行など) Fragment Colocation許可
Atoms x x x x
Molecules Atoms x x x
Organisms Atoms, Molecules, Organisms o o
Templates Atoms, Molecules, Organisms o x x
Parts Templates o o o
Pages Templates o o x

Partsが生まれた背景としては、タブが複数個ありそれぞれの中身で別々のAPIを実行しているが、PagesでAPIを実行してしまうとデータが変更された場合などに画面全体(全てのタブ)が再描画されてしまうことになります。これではパフォーマンスが良くないので、PagesではないがAPIを実行する要素がほしい!となり、Partsが誕生しました。(GraphQLを使っているので、OrganismsはFragment Colocationの定義のみでAPIの実行はPagesのみで行うという設計でした)

なぜAtomic Designをやめたのか?

結論を言うと、要素数が多く煩雑さが増してしまい認識齟齬や無駄な記述が多くなり開発速度が出なくなったからです。

  • ダイアログやBottomSheetはOrganismsなのか、Partsなのか?
  • OrganismsでAPI実行許可されていないので、簡単なAPI実行する処理でもPartsを作らないといけない(複雑なOrganismsもあれば簡単なPartsも存在してしまって違和感があった)
  • 役割の変更によりレイヤーの移動が発生する変更コストがある(ex. Organismsで作成していたが通信が必要になったのでPartsに変更する)
  • すでに実装済みのUIコンポーネントだったが、Organisms、Partsの認識が揃っておらず重複したUIコンポーネントを作成していた
  • ロジックはFlutter Hooksにより隠蔽できていおり、Pages, Templatesの分割のメリットが無く無駄な記述が増えている

新たな画面を開発する時に、レビュアーとレビュイーで要素のレイヤーが合わず相談&手戻りも発生している状態でした。

New UI Component Architecture


これらの課題感からチームで話し合い、シンプルでわかりやすい設計にすることにしました。

以下の基準により要素を分けることにしました。

  • ドメイン依存の有無
  • RouteのDestinationとなれるかどうか

この基準に沿って3要素(Parts, Compounds, Pages)に分割を行いました。

ドメイン依存有無 RouteのDestinationとなれるかどうか 旧要素
Parts x x Atoms, Molecules
Compounds o x Organisms, Parts
Pages o o Page, Templates

余談ですが、弊社がコンパウンドスタートアップを目指しているのでCompoundsとなりました!

それでは、各要素の説明をしていきます。

Parts

  • どこのアプリでも使えるようなUIコンポーネント(パーツ)
    • Atomic DesignにおけるAtoms, Molecules
  • ドメインには依存しない
  • 状態は持たない
  • Parts同士を組み合わせても良い
    • Button + Labelを持つPartsも作れる
  • Widgetbookを活用しPartsを把握・確認できる

Compounds

  • ドメインに依存するUIコンポーネント
    • AtomicDesignにおけるOrganisms
  • ドメインに依存する
  • 状態を持ってよい
  • 共通パーツとして使う側面もあるため、データ取得は積極的に行わずFragment Colocationを利用する

Pages

  • UIの最終形態
  • route定義としてでてくる
    • DialogやBottomSheetはrouteの定義として出てこないのでCompoundsとして定義する

要素数が半分になり、基準も明確になったかと思います。

Atomic Designと同じ用にまとめるとこちらになります。

要素 依存可能UIコンポーネント ドメイン依存 Data取得(API実行など) Fragment Colocation許可
Parts Parts x x x
Compounds Parts, Compounds o o o
Pages Parts, Compounds o o x

おわりに


新たな設計に変更してから日は浅いですがUIコンポーネントを作成するための基準が明確になったり、不要な要素がなくなったことにより悩むことが少なくなり開発速度改善に繋がっていると実感しております!

モバイル開発について気になることがあればお話しましょう!

jobs.layerx.co.jp


ハタラクをバクラクにするプロダクトを一緒に開発していきたい人を大募集中です!もしご興味ありましたら、ぜひカジュアル面談からお話させてください!LayerXオフィスでカジュアルにドリンクを飲む会を企画しました。こちらも是非ご覧ください!

jobs.layerx.co.jp

採用情報はこちら↓ jobs.layerx.co.jp