シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

S3とLambdaでEXIF 情報除去機能を作成する

SREチームの下野です。

以前こちらの記事で紹介したリアルタイム画像リサイズAPI に対し、 その前段で EXIF情報除去(および画像の回転補正)を行う仕組みを追加する対応を行いました。
今回はその対応や検討の経緯を紹介させていただきたいと思います。 tech.synchro-food.co.jp

前提

リアルタイム画像リサイズAPIとは

以下のようにCloudFront 、API Gateway、Lambda、S3(Public Bucket)を組み合わせて動的に画像をリサイズするAPIです。
弊社の各サービス共通で画像のリサイズに使用しています。

リアルタイム画像リサイズAPIの処理内容は以下の通りです。

  1. ユーザーがCloudFrontにリクエストを送ります。初回リクエストはCloudFront上にキャッシュが無いため、CloudFrontはオリジンとして設定しているS3のResizedBucketにリクエストを流します
  2. CloudFrontから流れてきたリクエストを受け、S3はBucket内を探しますが、Resizeされた画像が存在しません
  3. 画像が存在しない場合、307レスポンスが投げられます
  4. CloudFrontはS3からの307のレスポンスをそのままユーザーへ戻します
  5. ユーザーは307レスポンスを受け、次は画像変換APIのURLを待ち受ける、APIGatewayにリクエストを投げます
  6. API GatewayよりLambdaの画像変換関数がキックされます
  7. LambdaがS3のOriginalBucketより、画像リサイズ元となる画像を取得します
  8. 7で取得した画像をパラメータに従ってリサイズし、ResizedBucketに保存します
  9. 8で保存した画像へのURLへの301レスポンスをLambdaにて返します
  10. 301レスポンスをそのままユーザーに返します
  11. ユーザーは301レスポンスに従い、もう一度リサイズ画像のリクエストを送ります
  12. CloudFrontからResizedBucketにリクエストが流れ、変換後画像をレスポンスとしてキャッシュしつつ、結果をユーザに返す

詳細はこちらの記事をご参照ください。

tech.synchro-food.co.jp

EXIFとは

EXIF(EXchangeable Image File Format)とは、デジタルカメラやスマートフォン等が JPEG や TIFF(一部 HEIF 等を含む)に埋め込むメタデータ規格です。
撮影日時や、端末・カメラの機種、 位置情報、画像の向き情報などを保持しています。

EXIF情報を保持した状態で画像を公開すると、悪意のある利用者によって写真を撮影した際の位置情報等から個人情報の特定につながる可能性があり、削除しておいたほうが安全です。
また、EXIFは画像の向き情報を保持しており、EXIF情報を除去すると画像の正しい向きがわからなくなる場合があるため、EXIF情報を除去する際は除去前に画像の向きの補正をする必要があります。

背景

これまでリアルタイム画像リサイズAPIの構成では、使用しているS3がPublic Bucketのため アップロードされた画像がEXIF情報を保持している場合、EXIF情報ごと画像を公開してしまう可能性がありました。
そのため、リアルタイム画像リサイズAPIを使用する前に、アプリケーション側でEXIF情報を除去して画像をアップロードしていました。

しかしアプリケーション 経由のアップロードでは、 画像が大きい場合に時間がかかることや、EXIF除去の処理がアプリケーション側の性能劣化に繋がる等の課題がありました。

これらの課題に対応するため、リアルタイム画像リサイズAPIを利用するアプリでは、ユーザーから直接 S3 へアップロードするような対応を検討していましたが、その場合これまでのようにEXIF情報をアプリケーション で削除できなくなる問題がありました。

リアルタイム画像リサイズAPIは各サービス共通で利用されているため、EXIF情報除去についても同じように利用できた方が良いため、機能を追加することになりました。

対応内容

構成の検討

方針

チーム内で構成を検討したところ、リアルタイム画像リサイズAPI へ以下のような仕組みを追加する方針になりました。

追加する仕組みの処理内容

  1. クライアントからプライベートS3へ画像をアップロードする
  2. S3からLambdaへアップロードイベントを通知する
  3. Lambdaが Private Bucketから画像をダウンロードする
  4. 画像の向き補正・EXIF情報除去を実行する
  5. 処理済みの画像をリアルタイム画像リサイズAPIの Original image bucketへ保存する

構成検討の経緯

以下の理由で既存のバケットを Public から Private に変えられないため、EXIF除去前の画像を公開していない場所で受け取る必要があり、ユーザーからの画像アップロードを受け取るPrivate Bucketを追加する方針になりました。
なお、Private Bucket にアップロードされたEXIF情報がある画像はS3のライフサイクルポリシーによって削除しています。

  • Original image bucket
    • EXIF情報が残っている画像ファイルが公開されないようにする対応であるため、単にリアルタイム画像リサイズAPI のOriginal image bucket をPrivate Bucketにできないかを考えましたが、Original image bucket内に公開している画像が存在したためできませんでした。
  • Resized image bucket
    • リアルタイム画像リサイズAPIは、リサイズ後の画像を保存するバケットでリサイズされた画像がない場合にリダイレクト処理を実行 しています。リダイレクト処理は、S3の 静的ウェブサイトホスティング機能 とリダイレクト機能を使用しており、Privateに変更するとリダイレクトが動作しなくなり、大規模な改修が必要になると想定されたため、実現したいことに対して影響が大きくこちらの設定変更での対応は難しいと判断しました。

EXIF除去処理の実装

EXIF除去を行う処理は、Pythonで実装しました。
画像の向き補正、EXIF削除にはWand(ImageMagickのPythonバインディング) を使っています。
Wandにした理由は、社内でImageMagickの利用実績が多かったためです。

以下の通り処理自体は比較的にシンプルなため、それほど苦労せずにコードを作成できました。

  1. Private Bucketから画像をダウンロード
  2. 画像の向き補正、EXIF情報除去
  3. 処理済みの画像をOriginal image bucketへ保存

しかし、性能面の調整に苦労をしました。
前提として、リアルタイム画像リサイズAPI の前段に処理を追加する構成であるため、高速に処理する必要がありました。

初めはコスト最適化を意識して Lambdaに搭載するメモリは 1~2GB(1792MB/2vCPU)で開始しましたが、以下の課題があることがわかりました。

  1. 画像の最大サイズと想定している20MB の画像でメモリが足りないことにより Runtime Exitエラーが起きる
  2. 画像のサイズを大きくしていくとImageMagick の処理に時間を要し、処理時間が 3 秒以上かかる

性能面の課題への対応 1

まずは、課題の1点目の対応をしました。
処理が正常終了しないと本番運用できないため、まずはRuntime Exitエラーが起きないようにLambdaのメモリを4096MB(3vCPU) に増やしました。
メモリを4096MB(3vCPU) に増やしたところエラーは起きなくなり処理性能も一定の向上があったものの、画像のサイズによっては処理時間が 3 秒を越えやすい状況でした。

性能面の課題への対応 2

2点目の課題への対応です。
最初は、1点目の課題への対応と同じく Lambdaの vCPU/メモリ の量を増やすことで改善が図れないかと考えました。
Lambdaの割当メモリを増やすことでの懸念はコストですが、Lambdaは実行時間に対してコストが発生するため、処理性能が良くなるのであればコストは大きくは増えないと考え、かける労力が少なく対応出来ると良いかと思ったためです。
効果があるかを確認するため、一旦 Lambdaの割当メモリを 4096MB -> 8192MB(5vCPU)にして試しましたが、処理時間にはごくわずかな差 しかありませんでした。

次に、ディスクIOが原因ではと考えました。
S3から取得した画像やEXIF情報を除去した画像は、Lambdaの一時ディスク上に保存していたためです。
Lambdaのメモリ上で、EXIF除去・画像の向きを補正するよう修正して試しましたが、こちらもわずかな差しかありませんでした。
性能が上がらなかったことの調査のため実行ログを見ると、ディスク IO が原因ではないことがわかりました。画像の向き補正に時間がかかっていたようです。画像サイズが約 10MB を超えると向き補正に要する時間が増加しており、この処理は Lambda の割当リソースを増やしても改善されないものでした。

その後もImageMagickの依存ライブラリの変更などを試したのですが、あまり効果はなく画像のサイズによっては処理時間がかかるケースがありました。
こちらについては、アプリ側でユーザー向けにアップロード中であることを画面に表示すること、過去の傾向的にサイズの大きい画像はアップロードされるケースが少なかったことから、今回は受容する方針になりました。

振り返り

今回の対応では、無事EXIF情報除去機能を追加することができました。既存の画像リサイズAPIの機能・運用を維持したまま、当初の課題のアプリケーション側での処理を回避するという目的を達成できた点は良かったと思います。

実装面では、Lambdaのメモリ増強や処理フローの見直しなど、性能面の課題にも対応しました。通常サイズの画像では十分な性能を確保できた一方で、大容量の画像ファイルに対しては処理時間が長くなるという課題が残りました。発生頻度や運用上の影響を考慮し、現状では許容することとし、将来的に改善していければと思っています。
性能面の課題への対応では、先にもっと深く調査をしておくべきだったというような反省がありますので次回の対応時に活かせるようにしたいと思います。

まとめ

本記事では、リアルタイム画像リサイズAPI に対し、前段で EXIF情報除去(および画像の回転補正)を自動的に行う仕組みを追加する対応をした経緯をご紹介しました。

この記事がどなたかのお役に立てれば幸いです。

複雑化していた Java の環境構築を Dev Container 化して Eclipse から移行しました

こんにちは、アプリケーション基盤チームの坂本です。
今回は Java の開発環境を Dev Container 化した対応について紹介します。

弊社の開発環境について

弊社では「飲食店ドットコム」をはじめとした飲食店向けのサービスを多数運用しています。
それらのサービスは元々全て Java で書かれていましたが、数年前から段階的に Rails への移植を行っています。
主要なサービスのいくつかはすでに移植が完了していますが、一部のサービスはまだ移植の途中であり、Java で実装された機能と Rails で実装された機能が混在しています。
また、社内システムの中には Rails 移植の工数をかけない判断が下されたものもあります。

このような状況のため、開発者は Java と Rails の両方の環境構築をして業務を行っています。
Rails の環境構築は docker compose を使って比較的簡潔な手順で構築できます。
一方で、Java の環境構築は以下のような問題が発生していました。

  • ローカルに Java、Eclipse、Tomcat などを入れて、手順書を見ながら手動で様々な設定をする必要がある
  • 環境構築手順がいくつも条件分岐していたり、複数の記事に分かれていたりと複雑化している
  • 依存関係解決のため、CodeArtifact の token を取得して Eclipse に渡す手順があり、手順書では毎日手動でやることになっていた
  • バッチを実行するために専用の手順書通りに設定を行い、切り替える必要がある
    • この切り替えが面倒なため、同じリポジトリを2つクローンして片方を Web アプリ用、もう一方をバッチ用にして凌いでいる人が複数人いた

そのため、新しくインターンや新卒の方が入った際には環境構築に詰まって社員に質問することが頻繁に発生したり、環境構築に2日以上かかってしまう場合があるなど、何かしら改善したい状況でした。

今回の対応の概要

前述のような問題があったものの、以下のような理由から対応されないままになっていました。

  • 複数のチームにまたがって利用する環境のため、対応するチームが明確に決まっていない
  • 一度構築すれば当分は再度構築する必要がないこと
  • 弊社の人員増加は緩やかなためオンボーディングを改善する優先度がそれほど高くないこと
  • 私を含め最近入ってきたメンバーは Java の環境や歴史的経緯に詳しくなく、触りにくさを感じていたこと

そこで、今回の対応では弊社の価値創造と呼ばれる制度を利用して Java の開発環境の Dev Container 化を行っています。
この制度では、毎月業務時間の10%以下の時間であれば、保守性が気になっている部分をリファクタリングしたり、業務改善用のアプリケーションを作成したりすることができます。

今回の対応では以下の点を重視しつつ Java の開発環境を Dev Container 化しました

  • Dev Container で簡単に構築できるようにする
  • Eclipse で構築した環境とゆるく共存でき、段階的に移行できるようにする

今回の対応のポイント

ここからは今回の対応のポイントをいくつかピックアップして紹介します。

Tomcat

弊社では、Java で書かれた画面と Rails で書かれた画面が混在しているアプリケーションがあるため、開発環境では全てのアクセスが一旦 Apache を通り、そこで URL のパスなどから Rails と Tomcat に振り分けられます。
Tomcat では複数の Java のアプリケーションが動いていて、設定に応じてさらに振り分けられます。
そのため、各アプリケーションの Dev Container 環境に Tomcat を入れて違う port で動かす場合、Apache の設定も変更しなければいけません。
また、その際に既存の方法で構築した環境も壊れないようにする必要もあります。
さらに、構築手順の完全移行後に余計な設定を消す対応も必要になります。
そこで、今回の対応では1つの Tomcat で複数の Java アプリケーションが動く現状の構造をそのままにすることで、Apache は何も変更せずに対応することにしました。

これを実現するため、Tomcat のリポジトリを用意しました。
利用者はこれを clone して docker compose up -d することで Tomcat が起動します。設定などは不要です。
起動しておけば各アプリケーションの Dev Container 環境でビルドされた成果物が volume 経由で自動的に配置される仕組みです。
これによって、一度起動したら特に意識することもなく、今まで通り開発環境の URL にアクセスすれば Apache 経由で Dev Container 環境でビルドしたアプリケーションにつながります。

Tomcat のリポジトリは compose.yml や tomcat の設定ファイルなどを含んでいます。
compose.yml はおおまかに以下のようになっています

services:
  shared-tomcat:
    build: .
    container_name: shared-tomcat
    command: ["/opt/tomcat/bin/catalina.sh", "run"]
    volumes:
      ...
    ports:
      - "8080:8080" # HTTP
      ...
    networks:
      - hoge
    environment:
      - CATALINA_OPTS=...

volumes:
  # Java のビルド結果を格納するボリューム
  # Dev Container と共有するためnameをつける(つけないとvolume名にprefixが付く)
  java_build_target_hoge_app:
    name: java_build_target_hoge_app
  java_build_target_huga_app:
    name: java_build_target_huga_app
  ...

# DB などのコンテナがあるネットワークに接続。DB などはRails環境と共通
networks:
  hoge:
    external: true

Apache 側を変えなくて良いように port を被せているので、Eclipse 側で使っている Tomcat は停止する必要があります。
ただし、新しい環境構築方法で何か問題が発生した場合には単に Dev Container 用の Tomcat を停止して、Eclipse 側で使っている Tomcat を起動すれば元の環境に戻せます。
これにより、問題が発生しても旧環境に戻して開発を続行できるため低リスクで移行できます。
同時起動ではないので完全な共存と書くと誤解を招きそうですが、Tomcat を起動したり止めたりするだけで切り替えできるので"ゆるく共存"と書いています。

アプリケーション側の構築

アプリケーション側では、基本的にReopen in Containerをするだけで Dev Container 環境がビルド・起動され、環境構築が完了します。

アプリケーション側の Eclipse 側環境との干渉防止

アプリケーション側でも、Eclipse で構築した環境などと干渉しないための対策をいくつか行っています。

まず、Maven の pom のプロファイルを分けています。
いくつかのプラグインの設定を変更したりしたいのですが、そのままだと Eclipse で構築した環境と干渉してしまいます。
そこで、Dev Container 環境では環境変数を設定し、その環境変数がある場合はプロファイルを切り替えるようにしています。
これによって Dev Container 環境以外に影響を及ぼさずに依存解決やプラグイン・ビルドに関する設定を変更することができます。

<profile>
  <id>devcontainer</id>
  <activation>
    <property>
      <!-- DEVCONTAINER という環境変数が true の場合に有効になる -->
      <name>env.DEVCONTAINER</name>
      <value>true</value>
    </property>
  </activation>
  <build>
    <plugins>
      <plugin>
      ...

バッチのビルドに使うプロファイルも同様に他の環境と分けています。
バッチのビルドでは、Eclipse が自動的に解決してくれていた依存関係がいくつかあるため、このプロファイルでのみそれらを CodeArtifact から取得するように変更しています。

また、Dev Container 専用の JNDI 設定を記述したファイルを用意し、Dev Container 環境ではそちらを読み込むようにすることで、環境による差異を吸収しています。

アプリケーション側の便利機能

構築される環境には以下のような特徴があります。

CodeArtifact の token 自動取得

Dev Container 環境起動時に自動的にホスト側で CodeArtifact のトークンが取得され、コンテナの環境変数に渡されます。
キーチェーンのパスワード入力と MFA コードの入力は必要ですが、これは手動対応でも必要だったのでそのままにしています。

バッチ実行の簡易化

Dev Container 環境では、VS Code のタスクを実行するだけで簡単にバッチをビルドしたり実行したりできるようにしています。
VS Code のタスクでは以下のように args を指定することで引数を指定できるので、それをバッチ実行用のシェルスクリプトに渡しています。
これによって専用手順での切り替えは必要なくなり、同じリポジトリを2つクローンするなどの回避策も不要になりました。

// バッチ実行タスク(引数指定可能)
{
  "label": "Execute Batch",
  "type": "shell",
  "args": [
    "${input:taskClassName}",
    "${input:taskArgs}"
  ],
  ...
}

// 入力変数の定義
"inputs": [
  {
    "id": "taskClassName",
    "type": "promptString",
    "description": "実行するタスククラスの完全修飾名を入力してください (例: synchrofood.hoge.HogeTask)"
  },
  {
    "id": "taskArgs",
    "type": "promptString",
    "description": "タスクの引数を入力してください。なければ空欄のままEnterしてください",
    "default": ""
  }
]

デバッガーの利用

デバッガーについても VS Code 標準の Run And Debug を押すだけで対応可能です。
Tomcat に接続されてブレークポイントなど自由に使用できます。

ログの閲覧

ログについても Dev Container 環境起動時に自動的に Tomcat にあるログが自動で tail コマンド によって表示されるようになっています。
間違ってそのターミナルを閉じてしまった場合などは VS Code のタスクでいつでも開くことができます。
旧環境では全てのアプリケーションのログが混ざっていましたが、新環境ではそのアプリケーションに関連するログだけ閲覧できます。

導入後の経過

導入後1ヶ月ほど経っていますが、今のところ特に問題の報告はないようです。
逆に「このアプリケーションにも導入してほしい」といった要望はあったため対象を拡大中です。
このまま問題がなさそうであれば、環境構築手順書を更新し、正式な手順にしたいと思っています。

まとめ

今回の対応によって、複雑化していた Java の環境構築が Dev Container の起動と Tomcat コンテナの起動のみで完結するようになりました。
これにより、Javaの環境構築にかかる時間が劇的に短縮され、手順も簡素になりました。
また、ローカル環境に Java や Eclipse、Tomcat などを入れる必要がなくなり、手動での設定もほぼ不要になりました。
毎回手動で行っていたトークン取得が自動化されたり、バッチ実行が簡単になり切り替えも不要になるなどの嬉しい変化もありました。

「内装建築.com」から「店舗デザイン.COM」へのデータ移行対応の紹介

こんにちは、開発部デザイン開発チームの神尾です。

今回は、弊社サービスの「内装建築.com」から「店舗デザイン.COM」へのデータ移行の対応をしたため、その内容と苦労した点について紹介したいと思います。

「内装建築.com」、「店舗デザイン.COM」とは

「内装建築.com」は約2年前に他社から事業譲受したサービスになります。
店舗を構えたい施主さんとその内装デザインを請け負うデザイン会社さんをマッチングするサービスです。

「店舗デザイン.COM」は弊社が2005年から運営しているサービスになります。
「内装建築.com」と同様に施主さんとデザイン会社さんをマッチングするサービスなのですが、施主さんやデザイン会社さんの層、工事内容などが異なるというものになります。

データ移行対応の概要

今回、「内装建築.com」と「店舗デザイン.COM」のマッチングサービスを統合することになりました。
それにあたって、「内装建築.com」の会社さんのうち、希望いただいた会社さんのデータを「店舗デザイン.COM」に移行する対応をしました。

移行対象のデータはそれぞれの会社さんの会社情報や事例情報のデータになります。
また、それぞれに関連する画像も移行しています。

移行対象データの詳細:

  • 会社情報: 約20社
  • 事例情報: 約200件
  • 関連画像: 約1,000枚(複数サイズ含め約4,000ファイル)

移行する際に苦労した点と解決方法

データを移行するにあたって、主に2点が課題になりました。
ひとつはデータ構造の違い、もう一つは大量の画像データです。

データの構造の違いについて

「内装建築.com」は元々他社サイトということもあり、「内装建築.com」と「店舗デザイン.COM」は別々のアプリケーションです。
フレームワークも違えば、作られた時期、設計思想も違います。
データ構造も当然違います。
ここで意識しなければいけないのは以下の2点でした。

  • バリデーションの違い
  • テーブル構造の違い

バリデーションの違いの解決

例えば、事例情報のうち店名のフリガナのデータについて、「店舗デザイン.COM」では全角カナのみしか登録できませんが、「内装建築.com」は中点(・)など一部の文字も許容されます。
つまり、例えばフリガナが「シンクロ・フード」の店舗はそのままでは「店舗デザイン.COM」に移行できません。

その対策として、データ移行をする前に「内装建築.com」側のデータにバリデーション違反がないかのチェックをしました。
方法としては、SQLで「店舗デザイン.COM」側のバリデーションを再現し、こちらに違反するデータを取得し、事前に修正しました。
なお、バリデーション違反のデータをどのように修正するかは機械的に決められなかったため、修正対応としては事業部側に依頼して手作業で修正いただいています。

例:店名カナのバリデーション違反を抽出するSQL:

SELECT
  *
FROM
  companies c
WHERE
  NOT(
    CHAR_LENGTH(c.name_kana) <= 50
    AND c.name_kana ~ '^[ァ-ヴー]+$'
  );

この対応によって、「店舗デザイン.COM」側のバリデーションに合った形でデータを移行することができるようになりました。

テーブルの構造の違いの解決

例えば、資格情報について、「内装建築.com」は業種や取得年など細かくカラムが分かれています。
一方で「店舗デザイン.COM」は資格情報は自由入力で、細かい書式などはユーザーに任せられています。
そのため、移行時は「内装建築.com」側の資格情報のカラムのデータを1つにまとめる必要があります。

「内装建築.com」の資格情報のカラム:

カラム名 内容
category int4 業種
licenser int4 許可の種類
division int4 許可区分
acquisition_year int4 取得年
number varchar(255) 号数

「店舗デザイン.COM」の資格情報のカラム:

カラム名 内容
license longtext 資格情報

このようにテーブルの構造が違うと、データをそのまま移行できません。
そのため、「内装建築.com」からデータを取得する際、「店舗デザイン.COM」側に合わせた構造に変換してデータを取得することで、移行を可能にしました。

資格情報のデータをまとめるSQL(一部省略):

SELECT
  ARRAY_TO_STRING(
    ARRAY_AGG(
      CONCAT(
        CASE pl.category
          WHEN 0 THEN '(土)土木一式工事 '
          -- (中略)
          WHEN 29 THEN '二級建築士事務所 '
          ELSE ''
        END,
        -- (中略)
        CASE pl.number IS NULL
          OR CHAR_LENGTH(pl.number) = 0
          WHEN TRUE THEN ''
          ELSE pl.number || ''
        END
      )
    ),
    CHR(13)
  )
FROM
  professional_licenses pl
WHERE
  pl.category IS NOT NULL;

大量の画像データについて

次に課題になったのは大量の画像データでした。
今回対象となる画像は各会社さんのロゴ画像と事例の画像になります。
その合計は1000枚ほどあり、さらに1枚の画像から複数サイズの画像を用意しているため、実質的にはその数倍の画像を処理する必要がありました。
また、他にも以下のようなシステム的な制約があり、これらを考慮する必要がありました。

  • 移行先システムのダウンタイムはゼロで実施する必要があったこと
  • バッチを実行しているプロセスが他のバッチと共有されている為、長時間連続でのバッチ処理は実行できないこと

移行先システムの負荷軽減

移行先システムでは、画像アップロード時に一旦NFSに配置し、サイズごとに展開し、その後S3に同期というような仕組みを取っています。
通常利用でも画像を1枚アップロードするごとに処理時間がかかるため、そこへバッチで一気に大量の画像をアップロードしてしまうと、移行先システムを利用中のユーザがアップロードした画像の反映にラグが生じる危険性がありました。
その対策として、画像を移行する際、画像を1枚アップロードするごとに5秒間の待機時間を設けました。

バッチ処理の長時間化への対策

ただ、この対応により画像を1枚アップロードするのに8秒ほどかかるようになりました。
バッチ完了までに単純計算で2時間以上を要する計算になります。
先述の通り、今回は長時間連続でバッチを実行できないというシステム的な制約があるため、このままではその制約に違反してしまいます。
その対策として、画像移行バッチが一定間隔で処理の中断と再開を繰り返すようにすることで、長時間実行を回避することにしました。

冪等性の担保

バッチを中断しても、再度そこから実行できるようにするためには、冪等性を担保しなければいけません。
今回は各画像がアップロード済みかどうかの情報をテーブルで管理し、バッチ実行時にアップロード済みの画像はスキップして実行するようにしました。
これにより、バッチを実行する際には前回完了したところから再開できるようになりました。

なお、アップロード済みかどうかは次のように判定しています。

  • 画像のアップロードが完了次第、アップロード後の画像のカラムにファイル名が保存される
  • そのカラムにデータが入っていればアップロード済みと判定する

進捗管理テーブルのアップロード後の画像カラム(例:会社ロゴ):

カラム名 内容
new_company_logo_image varchar(255) アップロード後会社ロゴ画像

アップロード済みでない画像を取得する処理:

画像データのリスト = 進捗管理テーブル.where(
  new_company_logo_image: nil,
)

また、画像のアップロード処理において、エラーが発生しても自動的にやり直すように実装しました。
アップロード処理ではアップロードが完了したタイミングで先述のテーブルのアップロード後の画像カラムにデータを入れているため、アップロード中にエラーが発生した際はこのテーブルに情報が保存されません。
そのため、その画像はまだアップロードされていないという判定になり、次回実行時に再実行(やり直し)されます。

なお、厳密には中断処理は1枚の画像アップロードの処理が終わったタイミングで指定の時間を超えていないか確認する形で実装しているため、中断処理によってアップロード処理が途中で止まることはない実装になっています。

中断処理の実装:

start_time = Time.zone.now
max_duration = 30.minutes
画像データのリスト.each do |画像データ|
  break if Time.zone.now - start_time > max_duration

  # 画像アップロード処理
  sleep(5)
end

まとめ

今回は「内装建築.com」から「店舗デザイン.COM」へのデータ移行の内容と、苦労した点について紹介しました。

移行結果:

  • 移行データ数: 会社約20社、事例約200件
  • 画像移行数: 約1,000枚(複数サイズ含め約4,000ファイル)
  • データ整合性: 100%(移行後検証済み)

紹介したような問題など、対応に苦労した点もありましたが、最終的には無事予定通り希望いただいた会社さんのデータ移行を完了することができました。 今回の経験も踏まえて今後も開発を頑張っていきたいと思います。

Rails アプリケーションのフロントエンドを webpack から Rspack に移行しました

はじめに

こんにちは。開発部の竹内です。
弊社のプロダクトの1つである モビマル におけるフロントエンドビルドツール刷新の取り組みについてご紹介します。具体的には webpack から Rspack への移行を行いましたので、手順や結果をお伝えしたいと思います。

既存のプロジェクトの構成

モビマルはRailsアプリケーションとして構築されており、Reactで書かれたフロントエンド部分はwebpackでバンドルされ、app/assets/ディレクトリに出力されていました。その後、アセットパイプラインを通して利用するというワークフローになっていました。

ですが、この構成には以下の課題がありました。

  • webpack とアセットパイプラインの役割が重複し無駄になっている
  • 開発環境で Source Map が二重に出力されてブラウザから利用できなくなっている
  • 開発環境で HMR(Hot Module Replacement) ができない

特に HMR ができない課題は、変更のたびにページのリロードが必要になり、開発者体験を落とす結果に繋がっていました。

そこでフロントエンドビルドツールのモダン化を行い、開発効率・開発者体験の向上を図りました。

  • 脱アセットパイプライン
  • webpack-dev-server の導入(HMR の実現)
  • Rspack への移行

の三段階に分けて対応を進めました。
具体的な対応内容について以降のセクションで紹介していきます。

脱アセットパイプライン

webpack でビルドした bundle ファイルはアセットパイプラインを通さず直接 view から読み込むことを目標とします。
client/ ディレクトリにあるフロントエンドのコードをビルドして public/packs/ に出力し、 view ではそこから script タグを用いて読み込むように変更します。
ここで問題になるのがキャッシュコントロールのため必要となるフィンガープリントです。アセットパイプラインを使えば javascript_include_tag でフィンガープリントが自動で付与されますが、webpack で出力した場合は javascript_include_tag を使用できません。
そこで以下の記事を参考にwebpack-manifest-plugin を用い manifest.json を出力し、独自にフィンガープリント付きのパスで script タグを出力する helper を作成しました。

webpack-dev-server の導入

参考記事 と同様に webpack-dev-server の設定を行いました。これにより React の HMR が可能になり、コード変更時のページリロードが不要になります。具体的な変更内容は以下です。

  • webpack.config.js の設定
  • helper の変更
  • プロキシの設定
  • react-refresh-webpack-plugin の導入

これらの設定により HMR を実現できましたが、筆者の環境では webpack での HMR に10〜15秒程度の時間を要し、まだ改善の余地があると感じました。
上記を含めたコード例は次のセクションでまとめて掲載します。

Rspack への移行

フロントエンドビルド環境のモダン化を進めるにあたり、高速ビルドツールとしてViteRspackの二つの選択肢を検討しました。両者ともに十分なビルド速度を備えていましたが、違いを比較した結果僅差ではありますが Rspack を選択しました。

Vite Rspack
設定がシンプル。 設定は webpack と互換性が高い(やや複雑になる可能性がある)。
HMR 対応のためプロダクションコードの変更が必要になる。 現在のプロダクションコードで HMR 対応可能。
webpack から移行する場合の(Railsに統合するための)修正が多め。 webpack から移行する場合の修正が少ない。
採用実績は多め。 1.0が出たのが2024年8月なのでまだ採用実績は少ない。

当初は Rails から使用するための参考資料が多めの Vite への移行を想定していました。
ですが現在 webpack を使用しているため移行コストの低い Rspack を選択するメリットは多いと考え、こちらの採用を決定しました。

まずは必要なライブラリをインストールします

yarn add -D @rspack/core @rspack/cli @rspack/dev-server @rspack/plugin-react-refresh @swc/helpers react-refresh rspack-manifest-plugin
yarn add postcss

webpack.config.js の代わりに rspack.config.js を設定します。

const path = require('path')
const { rspack } = require('@rspack/core')
const { RspackManifestPlugin } = require('rspack-manifest-plugin')
const ReactRefreshPlugin = require('@rspack/plugin-react-refresh') // HMR を可能にする

module.exports = (env, argv) => {
  const isDevelopment = argv.mode === 'development'

  return {
    output: {
      path: path.resolve(__dirname, '../public/packs/'),
      filename: '[name]/webpack/bundle-[contenthash].js', // フィンガープリントをファイル名に含める
      publicPath: '/packs/',
    },
    entry: {
      'managers/offer_projects/opening_shifts':
        './src/entryPoints/Managers/OfferProjects/OpeningShift/index.tsx',
      // ...
    },
    resolve: {
      extensions: ['.js', '.ts', '.jsx', '.tsx'],
      modules: [path.resolve(__dirname, 'src'), 'node_modules'],
    },
    devtool: argv.mode === 'development' ? 'source-map' : false,
    module: {
      rules: [
        {
          test: /\.(j|t)sx?$/,
          exclude: [/[\\/]node_modules[\\/]/],
          loader: 'builtin:swc-loader', // 高速化のため babel-loader ではなく、推奨されている builtin:swc-loader を使用
          options: {
            jsc: {
              parser: {
                syntax: 'typescript',
                tsx: true,
              },
              externalHelpers: true,
              transform: {
                react: {
                  runtime: 'automatic',
                  development: isDevelopment,
                  refresh: isDevelopment,
                },
              },
            },
            env: {
              targets: 'defaults',
            },
          },
        },
        {
          test: /\.css$/,
          use: ['style-loader', 'css-loader'],
          type: 'javascript/auto',
        },
      ],
    },
    plugins: [
      // 無駄な locale を読み込まないようにする
      new rspack.IgnorePlugin({
        resourceRegExp: /^\.\/locale$/,
        contextRegExp: /moment$/,
      }),
      new RspackManifestPlugin({ // manifest.json を出力
        fileName: 'manifest.json',
        publicPath: '/packs/',
        writeToFileEmit: true,
      }),
    ].concat(isDevelopment ? [new ReactRefreshPlugin()] : []),
    devServer: {
      host: '0.0.0.0',
      port: 21201,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
      allowedHosts: [
        'localhost',
        'client', // docker-compose のサービス名が client なので、コンテナ内からアクセスできるようにする
      ],
    },
  }
}

script タグを生成する helper(javascript_bundle_tag) を作成します。

module GlobalHelpers
  module WebpackBundleHelper
    class BundleNotFound < StandardError; end

    # manifest.json を読み込んで、エントリーポイント名からファイルパス(client でビルドしたハッシュ付きファイル名)を取得する
    # @see https://inside.pixiv.blog/subal/4615#%E3%83%93%E3%83%A5%E3%83%BC%E3%83%98%E3%83%AB%E3%83%91%E3%83%BC%E3%81%AE%E5%AE%9F%E8%A3%85
    def asset_bundle_path(entry, **options)
      return '' if Rails.env.test? # テスト環境では manifest.json がない可能性があるので空文字列を返す

      raise BundleNotFound, "Could not find bundle with name #{entry}" unless webpack_manifest.key? entry

      asset_path(webpack_manifest.fetch(entry), **options)
    end

    def javascript_bundle_tag(entry, **options)
      path = asset_bundle_path("#{entry}.js")

      options = {
        src: path,
      }.merge(options)

      javascript_include_tag '', **options
    end

    private

      MANIFEST_PATH = 'public/packs/manifest.json'
      # 開発環境・テスト環境以外では manifest.json を1度だけ読み込む
      MANIFEST_CONTENT = Rails.env.development? || Rails.env.test? ? nil : JSON.parse(File.read(MANIFEST_PATH))

      def webpack_manifest
        if Rails.env.development?
          # dev-serverから取得する
          JSON.parse(OpenURI.open_uri("http://#{Settings.dev_server.host}/packs/manifest.json").read)
        else
          MANIFEST_CONTENT
        end
      end
  end
end

dev-serverからのアセット取得のプロキシ設定です。参考記事 ほぼそのままです。

require 'rack/proxy'

# dev-serverからのアセット取得をプロキシする -> localhost以外からもdev環境を見れるようにするため
# @see https://studist.tech/goodbye-webpacker-183155a942f6
class DevServerProxy < Rack::Proxy
  def perform_request(env)
    if env['PATH_INFO'].start_with?('/packs/')
      env['HTTP_HOST'] = dev_server_host
      env['HTTP_X_FORWARDED_HOST'] = dev_server_host
      env['HTTP_X_FORWARDED_SERVER'] = dev_server_host
      super
    else
      @app.call(env)
    end
  end

  private

    def dev_server_host
      Settings.dev_server.host
    end
end

Rails.application.configure do
  # ...
  config.middleware.use DevServerProxy, ssl_verify_none: true
end

package.json のビルドコマンドを rspack に変更します。

{
  ...
  "scripts": {
-   "build": "webpack --mode=development",
-   "build:prod": "webpack --mode=production",
-   "watch": "webpack --mode=development --watch"
+   "build": "rspack --mode=development",
+   "build:prod": "rspack --mode=production",
+   "dev-server": "rspack serve --hot --mode development"
  },
  ...

あとは各 view で javascript_bundle_tag を使うように書き換えれば対応完了です。 HMR を試したところ1秒以内に完了するため快適になりました。

まとめ

Rspack の導入で高速な HMR が可能になり、開発効率・開発者体験が向上しました。
今はモビマルのみ Rspack を使用していますが、他のプロダクトでも展開を検討しています。
Rails アプリケーションにおける Rspack の導入事例はまだウェブ上でも多く見られなかったため、本記事が参考になれば幸いです。

手続き的なCSVパーサーをDSLで宣言的に書き換えて保守性を改善した話

こんにちは。開発部 HR事業管理開発チームの田中です。
主に求人飲食店ドットコムの社内・代理店向け管理機能を担当しています。
今回は、チームで開発・保守しているCSV一括処理機能を技術的に改善しましたので、そのときのことについてお話しします。

なぜCSV一括処理の改善をしたのか

「求人飲食店ドットコム」に掲載される求人情報やその他関連情報は、シンクロ・フードの営業メンバーや提携している代理店の方々によって日々大量に作成・更新されています。
そのため、管理システムの入力フォームから一件ずつデータを入力するよりも、CSVファイルに情報をまとめて入力し、一括でデータを操作する方が効率的に業務を進められます。

こうした背景から、要望が挙がるたびにCSV一括処理機能の開発を繰り返し、その数を増やしていきました。

開発の際には、既存の実装をコピーし、その機能独自の仕様に合わせて修正を加えるという手法がとられていました。
一見すると、「コピーして少し修正するだけ」の単純な作業に思えますが、実際には無駄な工数の発生や、意図しない不具合を誘発するリスクを抱えていました。

例えば、CSVにはヘッダーの検証やデータ型のチェックといった、多くの機能で共通する特有のバリデーションが存在します。
しかし、それらが共通化されていなかったため、開発のたびに同じバリデーションの要件を再検討する手間がかかり、さらに考慮漏れが原因で不具合が発生するリスクを常に抱えていました。

これまでの実装と課題

まずは、これまでの実装がどのようなものだったかを紹介します。
これまでの実装を参考に、サンプルを書き起こしました。
CSVのヘッダをチェックしてから、1行ごとに入力値をチェックして、ハッシュに変換する流れで処理を行っていました。

class OldCsv
  def parse(csv_data)
    errors = []
    hashed_csv_data = []

    # CSVのヘッダなどのバリデーション
    raise << '空のCSVファイルが入力されました。' if csv_data.empty?
    errors << 'IDの列が存在しません。' if csv_data.headers.exclude?('ID')
    errors << 'ステータスの列が存在しません。' if csv_data.headers.exclude?('ステータス')
    # ... このようなバリデーション処理が続く ...

    CSV.parse(csv_data, headers: true).each_with_index do |row, index|    
      # CSVに入力された値のバリデーション
      errors << "#{index + 2}行目: IDは必須です。" if row['ID'].blank?
      errors << "#{index + 2}行目: 存在しないIDです。" unless User.exists?(row['ID'])
      # ... このようなバリデーション処理が続く ...

      # データをハッシュに変換
      hashed_csv_data << { id: row['ID'], status: row['ステータス'], ...
    end

    raise errors.join("\n") if errors.present?
    hashed_csv_data
  end
end

このような実装スタイルには、主に3つの課題がありました。

  1. 処理が逐次的で、CSVの全体像を把握しづらい
    処理が逐次的なため、一つのCSV列に対するバリデーションがコードの複数箇所に散らばっており、CSV全体の仕様(どんな入力値を求めているのかなど)を把握するのが大変でした。
    例えば、サンプルの「ID」列に対するバリデーションは、序盤のヘッダー存在チェックと、ループ内の必須チェックやDB存在チェックといったように、離れた場所に記述されています。
    CSVの列が増え、仕様が複雑になればなるほど、この傾向は悪化し、読みづらさは増す一方でした。
  2. 共通化の余地がある
    先述の通り、上記のようなパーサークラスが機能の数だけ存在していました。
    どのクラスも、ヘッダのバリデーション、行毎のバリデーション、ハッシュに変換する処理で構成されており、バリデーションの内容も共通化できるものが多く含まれていました。
  3. 後続処理との接続があまり良くない
    私の所属するチームで開発した一括処理機能はRuby on Railsで開発しています。
    なので、登録・編集機能の実装ではActive Recordのモデルを扱うのが基本ですが、これまでのパーサーの出力はハッシュの配列でした。
    そのため、この後の処理で、ハッシュの配列を一つずつモデルに変換し直す必要がありました。
    一見すると、ハッシュをnewupdateのattributesとして渡すだけで済むので、あまり大変ではないように思えます。
    しかし、enumやマスタテーブルを使う属性を持つモデルを扱う場合、CSV上での値と、DB上での値が異なることがあるため、ハッシュをそのままモデルに渡す前に、値を置き換えるというもう一手間が必要になっていました。
    例えば、CSV上では「正社員」という分かりやすい文字列で入力されていても、DBではenumで管理しているregularという内部的な値に変換する必要がある、といったケースです。

改善策の選択

先述の課題を解決するために、以下の要件を挙げました。

  • CSVの仕様をコードではなく、外部ファイルに定義できるようにする
  • 共通化できる処理は、可能な限りベースとなるクラスに集約して、責務を分離する
  • CSVに入力されたデータは、指定したモデルの配列に変換して出力する

これらを実現するためには、DSL (Domain-Specific Language)を導入した実装が最も良い方法だと考えました。

その理由は3つほどあります。

  1. 仕様を宣言的に記述できる
    DSLの最大のメリットは、「何を(What)」したいかを宣言的に記述できる点です。
    これまでの「どのように(How)」処理するかを一行ずつ記述する手続き的なコードとは異なり、「この列は整数型で、このCSVヘッダーに対応する」といった形式で、仕様を宣言的に記述できます。
  2. Ruby (Rails) はDSLと相性が良い
    Rubyはその柔軟な文法から、自然で読みやすいDSLを構築しやすい言語です。
    Railsのroutes.rbやActive Recordのhas_manyなども、DSLの一例です。
    私たちは日頃からRailsを使って開発しており、このRubyの特性を活かさない手はありませんでした。
  3. DSLをやってみたかった
    正直なところ、これが一番の動機かもしれません。

DSLを導入して、どう変わったか

DSLを導入することで、パーサークラスをスッキリさせることができました。
以前の複雑なロジックは消え、CSVの仕様を宣言的に記述するだけの、非常にシンプルなクラスになり、可読性が向上しました。

# DSLを用いてCSVの仕様を定義するクラス
# 例)求人一括登録CSVの場合
class JobCsv < CsvBase
  # 特定の値のみを入力値として受け付けたい場合は、このように定義する
  # '入力値として受け付ける値' => 'モデル変換時に属性に設定される値'
  EMPLOYMENT_TYPE_OPTIONS = { '正社員' => 'regular', 'アルバイト' => 'part_time' }.freeze

  # 変換先モデル(やフォームオブジェクト)を指定
  model Form::JobCsv

  # CSVの各列の仕様を定義
  column :draft_job_id,      '求人ID',    type: :integer
  column :employment_system, '雇用形態',  type: EMPLOYMENT_TYPE_OPTIONS
  column :salary,            '給与',      type: :string
end

まずmodelメソッドで、CSVの入力情報をどのモデルに変換するか指定します。
そして、columnメソッドで「モデルの属性」「CSVのヘッダ名」「データ型(もしくは、別途選択肢を定義)」を指定するだけで、必要なバリデーションやデータ変換が行われる仕組みです。

DSLを支える裏側の仕組み

先述のDSLのベースクラスです。
ここに、CSVの各種バリデーションとモデルへの変換機能を実装しました。

# CSVパーサーの共通処理を担うベースクラス
class CsvBase
  class << self
    attr_accessor :model_class
    
    # DSL: 扱うモデルを指定する
    def model(model_class)
      @model_class = model_class
    end

    # DSL: CSVの列と属性のマッピングを定義する
    def column(attribute_name, csv_header, type:)
      @mappings ||= {}
      @mappings[csv_header] = { attribute: attribute_name, type: type }
    end
    
    def mappings
      @mappings || {}
    end
  end

  # 読み込んだCSV、指定したモデル、定義した列・属性のマッピングをもとに、インスタンスを生成する
  def initialize(csv_data)
    @csv_data = csv_data
    @mappings = self.class.mappings
    @model_class = self.class.model_class
  end

  # ヘッダの過不足チェックや、入力値の型検証
  def validate
    errors = []
    # ... ヘッダーの過不足チェックなどの処理 ...

    @csv_data.each_with_index do |row, index|
      @mappings.each do |header, mapping|
        value = row[header]
        type = mapping[:type]
        case type
        when :integer
          errors << "#{index + 2}行目: 「#{header}」は整数で入力してください" unless value.blank? || value.match?(/\A-?\d+\z/)
        when Hash # 別途定義した選択肢を型として指定した場合
          errors << "#{index + 2}行目: 「#{header}」の値が不正です" unless value.blank? || type.key?(value)
          # ... (略) ...
        end
      end
    end
  end

  # モデルの配列への変換
  def to_models
    @csv_data.map do |row|
      attributes = @mappings.each_with_object({}) do |(header, mapping), attributes|
        value = row[header]
        type = mapping[:type]
        attribute = mapping[:attribute]

        # 別途定義した選択肢を型として指定した場合は、指定した値に置き換える
        converted_value = type.is_a?(Hash) ? type[value] : value
        attributes[attribute] = converted_value
      end
      @model_class.new(attributes)
  end
end
  • model, column
    これらがDSLの本体です。個別の定義クラスで呼び出され、modelは変換先のモデルクラスを、columnはCSVの各列の仕様を、それぞれインスタンス変数に記憶します。
  • validate
    columnで定義された情報を元に、バリデーションを実行します。
    ヘッダーの過不足チェックや、type:で指定されたデータ型(integerなど)に基づく型チェックといった、汎用的な検証をすべてこのメソッドが引き受けます。
  • to_models
    CSVデータを指定したモデルの配列に変換するメソッドです。
    CSVの各行を、columnmodelで定義された情報に従って、modelクラスのインスタンスに変換し、その配列を返します。
    このとき、typeにハッシュ(選択肢)が指定されていれば、CSV上の表示名(例:「正社員」)を、DBで管理している内部的な値(例:「regular」)に置き換えるといったことも同時に行います。

一括処理全体への改善効果

この改善によって、CSV一括処理全体の流れも非常にシンプルになりました。

# パーサーによる形式的なチェック
csv = JobCsv.new(csv_data)
errors = csv.validate
return if errors.present?

# モデルへの変換と、ビジネスロジックのチェック
products = csv.to_models # ここでモデルの配列が一括で手に入る
products.each(&:valid?) # ActiveRecordのバリデーションを実行
return if products.any?(&:invalid?)

# 保存処理
Product.import(products)

複雑なパースとバリデーションのロジックはパーサークラスに委譲され、処理を実行するクラスは「パーサーを呼び出し、モデルに変換し、保存する」という責務だけを行う、非常にシンプルで見通しの良いクラスになりました。

まとめ

今回は、手続き的な実装で保守性に課題があったCSVパーサーを、DSLを用いて宣言的に書き換えることで保守性を改善した事例をご紹介しました。
この記事が、同じような課題に直面している方々の参考になれば幸いです。

「ふりかえりカタログ」の振り返り手法をチームで実践してみました

こんにちは、開発部会員企画開発チームの日比野です。

今回は、私たちのチームで導入したいくつかの振り返り手法についてご紹介します。

背景

私たちのチームでは週次で KPT(Keep, Problem, Try) を用いた振り返りを行っていましたが、次第に意見が出にくくなり、チームとして取り組むべき課題も挙がりにくくなるという問題を抱えていました。

この状況を改善するため、私たちは新しい振り返りの手法を試してみることにしました。

ふりかえりカタログ

「何か新しい振り返りのやり方はないか?」と探していたときに見つけたのが、こちらの「ふりかえりカタログ」です。

qiita.com

このサイトには目的や状況に応じた様々な振り返りの手法がまとめられているので、この中から活用できそうな振り返り手法を選びました。

週次:感情グラフ + タイムラインのハピネスレーダー

私たちは、週次の振り返りの KPT で意見が出にくくなっていた原因を「そもそもその週にあった出来事を全員が思い出せていないのでは?」と考え、「出来事を思い出す手法」の中から新しいやり方を探すことにしました。

チームで検討した結果、週次の振り返りでは「感情グラフ」と「タイムラインのハピネスレーダー」を組み合わせた手法を導入しました。

具体的なやり方

  1. Miro のホワイトボードツール上に、横軸を曜日、縦軸を「🙂」「😐」「☹️」の3段階の感情としたグラフを用意します。
  2. チームメンバーは日々の業務の中で、何か出来事があったら付箋に内容を書き、その時の感情に合う位置に貼り付けます。
  3. 週次の振り返り会の時間で、その週に貼られた付箋の中から、特に話したいものや KPT の K(Keep) や P(Problem) に繋がりそうなものをピックアップし、全員で深掘りして話します。

やってみてどうだったか

KPT では意見として挙げるほどではないけれど、少しモヤっとした、あるいは嬉しかった、といった細かな感情に紐づく出来事をチームで共有できるようになりました。
今はまだ個々の出来事の共有に留まるものも多いですが、今後はこれをチーム全体の改善アクションに繋げていけるよう、運用を継続していきたいと考えています。

月次:象、腐った魚、嘔吐

少しインパクトのある名前ですが、これはチームに潜む「見て見ぬふりをされている問題」を話し合うための手法となっています。
実践してみたい振り返り手法として挙がったのですが、頻繁に意見が溜まっていくようなものではないため、出てきた意見は月次で確認することにして導入しました。

具体的なやり方

以下の3つの観点で、気になっているけれど普段は言い出しにくいことを付箋に書き出します。

  • 象🐘
    • 誰もがその存在に気づいているのに、あえて口に出さない(見て見ぬふりをしている)大きな問題や真実を指します。
  • 腐った魚🐟
    • 早く処理しないとどんどん臭くなっていく腐った魚のように、隠している悩みや過去の過ちを指します。
    • 早く打ち明けた方がよいけれど、なかなか言い出せないことです。
  • 嘔吐🤮
    • 普段は胸の内にしまっている不満や意見を、批判される心配なくすべて吐き出すことです。

月次の振り返りでは、これらの観点で集まった付箋について、心理的安全性を確保した状態で話し合います。

やってみてどうだったか

普段の振り返りでは議題に上がりにくい根深い問題やチームに対する本音を共有する場にできています。
テーマの性質上暗い雰囲気になりがちで、活発に意見を言い合って結論を出すということが難しい議題も多いため、一旦はチーム全体で共通認識を持つというところまでで留めておくことにしています。

オフライン:焚き火 + 闇鍋

振り返りの話からは逸れてしまいますが、ふりかえりカタログにある手法を用いたオフライン MTG も行ったのでそちらについても紹介します。
私たちのチームでは、定期的にオフラインで集まる機会も設けていて、その場ではチームのコミュニケーションをより深めることを目的としています。
そこでふりかえりカタログの「アイデアを出し合う手法」からヒントを得て、「焚き火」と「闇鍋」を組み合わせたワークを行いました。

具体的なやり方

  1. モニターに焚き火の映像と音を流し、リラックスした雰囲気を作ります。
  2. 各々が業務の話や趣味の話などに関連するテーマを付箋に書き、「闇鍋」のように一つの場所へ集めます。
  3. ランダムに付箋を1枚引き、そこに書かれたテーマについて全員で自由に雑談します。

やってみてどうだったか

この時に出たテーマとしては、「みんなはAIをどう活用している?」「業務で使っている便利なツール教えて!」などがありました。
普段はチームで雑談をする時間を設けていないため、知見を共有し、チームメンバーの普段の様子を知ることのできる有意義な時間になりました。
焚き火については、オフラインでの MTG なので周りにある程度の雑音もあり、MTG もチーム全員で行ったため無言になることも少なく、特に大きな効果はなかった印象でした。シーンとした空気になりやすい MTG の時に利用する方がメリットを感じられそうです。

まとめ

今回は、私たちのチームで試した振り返りの手法について紹介しました。

振り返りの目的を意識し、チームの状態に合わせて手法を柔軟に変えていくことで、振り返りのマンネリ化を防ぎ、継続的な改善に繋げることができると感じています。

もしあなたのチームでも同じように振り返りの課題を感じていたら、「ふりかえりカタログ」をヒントに、何か新しい手法を試してみてはいかがでしょうか。

RubyKaigi2025に参加した弊社メンバーで感想を話し合いました

開発部・アプリ基盤チームの深野です。
今回、弊社から自分を含む3名が松山で開催されたRubyKaigi2025に参加して色々なセッションを聞いてきました。
その3名で印象に残ったセッションのことや参加しての感想などを話し合ったものを文字起こしして、再構成したものが今回のブログになります。
なお、弊社はSilver SponsorとしてRubyKaigi2025に協賛させていただきました。

自己紹介

竹内: シンクロフード3年目の竹内と申します。普段は主にモビマルというキッチンカー関連のサービスの開発を行っています。Rubyに関しては、高校生くらいの時からやっていて、もう20年くらい前から触れています。RubyKaigiに関しては、今回が初参加となります。よろしくお願いします。

小島: 去年新卒で入社して今年で2年目の小島です。Ruby自体は入社前に1年内定者インターンで利用していたので今年でRuby歴3年目に入りました。3年目ですがRubyに関してはまだまだ初心者です。RubyKaigiの参加は初めてで、東京Ruby会議に今年の1月に参加しています。よろしくお願いします。

深野: 新卒でシンクロフードに就職してから、現在6年目になる深野です。Ruby歴は5年ほどです。RubyKaigiには、去年から続けて2回目の参加になります。RubyKaigi以外の技術カンファレンスですと、Kaigi On Railsに去年参加しました。よろしくお願いします。

道後温泉の近くの愛媛県民文化会館という大きな会場をほぼ貸し切ってイベントは行われていました

印象に残ったセッション

Make Parsers Compatible Using Automata Learning

深野:竹内さんにまずは聞いてみます。今回RubyKaigiに参加した中で印象的だったセッションを1つ教えてください。
竹内: 僕はパーサーに興味を持っていたので、1日目の「Make Parsers Compatible Using Automata Learning」というセッションに興味を持ちました。
深野: そのセッションは具体的にどのような内容でしたか?
竹内: Rubyのパーサーにはparse.yとPrismという2つがあるのですが、その互換性を形式的な手法を使って違いがないかを確認するという手法の紹介でした。

小島: そもそも普段Rubyを書いていてパーサーが気になることってあまりない気がするのですが、なぜそもそもパーサーに興味があったのでしょうか?
竹内: そうですね。昔から言語処理系などを作っていたので、その時パーサーの手法などを色々調べていたという感じです。
小島: なるほど。昔から言語処理系を作っていたんですね。

小島: 深野さんも何か質問はありますか?
深野: 具体的にどのようにその2つのパーサーに互換性があるということを示したのでしょうか?
竹内: Automata Learningという手法が用いられています。ブラックボックスになっているパーサーを外からモデル化して、その内部構造を自動的に推定するという手法になっています。言語全体をモデル化するのは難しいのですが、その一部に着目することで違いを見つけていくことは可能だそうです。
深野: なるほど。ブラックボックスということはCやRubyなどで書かれたパーサーを静的解析したりするのではないのでしょうか?
竹内: 静的解析というよりは、ブラックボックスとしてパーサーを実際に実行した振る舞いから分析する手法となっています。そのため他の言語で書かれていてもこの手法を用いることが可能だと思います。
深野: なるほど。振る舞いの方を見るということですね。

深野:内容はなんとなくわかったのですが、どこが面白いと感じたのでしょうか。
竹内: そうですね。パーサーの振る舞いからオートマトンを形成するというアルゴリズムなのですが、一般的なRubyなどの文法では正規文法には収まりません。そのためどのようにオートマトンを作成していくのかが気になりました。正規言語ではないのでDFAなどでは扱えませんが、VPA(Visibly Pushdown Automata)と呼ばれる、正規言語より強い言語を表現できるオートマトンを使うというのが興味深かったです。文脈自由文法よりは弱いので全ては表現できないんですけども、今回の用途に実用できるほどの表現力を持ったオートマトンということで、興味を持ちました。
深野: オートマトンの授業で習う初歩的なものよりは広いけど文脈自由文法まではカバーできないオートマトンがあって、それを使ってパーサーの動きをオートマトンで表現したということなんですね。こんなことができるとは知らなかったです。
竹内: 一般的なオートマトンの授業で習うのがDFAやNFAだと思うんですけど、その次にプッシュダウンオートマトンと言って文脈自由文法を表現できるオートマトンの一種があって、VPAはその中間に位置するものですね。これだと、DFAのように共通部分や差分などを計算できつつ、DFAよりも強いという性質を持つ文法になっているそうです。

Ruby Taught Me About Encoding Under the Hood

深野: では、小島さんはRubyKaigi初参加だと思うのですが、面白かった講演はどちらでしょうか?
小島: 僕が印象に残っているのは初日のキーノートでima1zumiさんという方がお話されていた「Ruby Taught Me About Encoding Under the Hood」という、文字コードエンコーディングの発表です。
深野: 私と竹内さんは内容を知ってはいるのですが、ご存知ない方のために講演内容を教えてください。
小島: 1時間の発表なので内容が結構あるんですけど、最初に文字エンコーディングの歴史を手早く紹介して、その後、エンコーディングのハマりやすいところについて実体験を交えながら発表されていました。最後は、今後Rubyで文字コードエンコーディング関係のどういう改善をしたいかっていう話をしていただきました。

深野: 面白かった点は何かありますか?
小島: そうですね、僕はRubyKaigiに参加しているのにそんなにRubyに詳しくなかったのですが、Rubyに詳しくなくても話を通してわかるというのがまず助かったポイントではあります。面白かった点としては、半角カタカナがなぜ何のために生まれたかみたいな話だったり、普通のひらがなとかだと2バイト以上使っていると思うのですが、そういう文字の内部での扱いの話が面白かったです。他には、書記素クラスタという、僕ら人間が見た時の文字数みたいな概念があるらしいのですが、その話も面白かったです。
深野: ありがとうございます。そうですよね。私も書記素クラスタの概念などは知らなかったので勉強になりました。家族の絵文字が表示されるのは1文字なんですけど、内部的には4つのコードポイントで構成されているみたいな内容ですよね?
小島: 先ほど発表を見直したら7コードポイントでしたね、内部的には。
深野: あ、7つ。結合文字(Zero Width Joiner)があるからですね。内部的に7つのコードポイントを結合して、幅的には1文字の家族の文字ができてるみたいなのは知らなかったです。

「Å」や「び」のように単一のコードポイントでも表せるものと、🧑‍🧑‍🧒‍🧒のように7つのコードポイントでしか表せないものがあるというのも面白い点でした

竹内: 僕の興味を惹かれた点を言うと、Unicode15.1.0で追加された Indic Conjunct Break プロパティがあります。昔サンスクリットと一緒にデーヴァナーガリーを学んだ際に、文字の結合がかなり複雑になっていて苦労したのを思い出しました。例えば क् と ष が連続すると क्ष という全く見た目が異なる文字になったりします。
深野: なるほど。文字って奥が深いですよね。私はアルファベットと日本語の文字ぐらいしか詳しくないですけど、世界の他の国を見ていけばなんか違うルールだったり違う体系の文字がありますよね。西洋のソフトウェアのアスキー文字以外への解像度の低さに苦しめられてきた日本人としては、世界の多様な文字をソフトウェアでサポートしていってくれるUnicodeの新しいバージョンには積極的に追従していきたいですね。

On-the-fly Suggestions of Rewriting Method Deprecations

竹内: それでは深野さんは、今回のRubyKaigiで興味を持ったセッションはありましたでしょうか?
深野: 「On-the-fly Suggestions of Rewriting Method Deprecations」という講演が面白かったです。
竹内: 内容についてはどのようなものだったでしょうか?
深野: はい。こちらの内容は、プログラミングやライブラリのDeprecation Warning、非推奨警告の対応方法について整理した上で、Rubyについて今のシステムよりももっと自動化されたようなものを提案しているという内容です。具体的には、まずDeprecation Warningに関して、既存のプログラミング言語がどういった方法で対応しているのかをそれぞれ列挙した上で、それらの対応方法を自動化具合のレベル別に5段階でまとめています。著者はこのように5段階で表現した中で、Rubyで5段階目の自動化のその先まで考えようという内容になっています。

小島: 僕もこのセッションを聞いたのですが、ちょっと結末を覚えていなくて、Rubyで結局どうしようみたいな結末になったのでしょうか?
深野: 結末としては、発表者はPharoというプログラミング言語を参考にして、Deprecation Warningのランタイムでの自動修正ができるようなgemを作ったんですが、実際それにはまだかなり制約があるということでした。技術的な制約がまずありますが、それよりさらに大きいものが、Rubyのエコシステムがこれをそもそも採用するのかというところですね。現状、これは単なるサードパーティgemであって、言語の組み込み機能ではないですよね。言語の組み込み機能としてDeprecation Warningの書き換えをサポートしているようなものはC#などがありますが、Rubyはそうではありません。この機能を使うには結局gemの作者がこのgemを認識してもらうとか、逆にgemを使うユーザー側が自分でDeprecation Warningの書き換えルールを自分で書いて設定する必要があるという意味で、提案はしたけど、実際はまだまだ難しいんじゃないかという結論だったかなと思います。
小島: なるほど難しいのですね。ただ、個人的にはランタイムでの自動修正があったら嬉しいなと思うのですが、深野さんはどうでしょうか?
深野: はい。嬉しいなと思います。今は弊社ではDeprecation WarningについてはランタイムでSentryに通知して、それを見て手動でプルリクを作って直すということをやっているんですけれども、やはりどうしても手間がかかります。ランタイムでの自動修正みたいなところができれば、言語だったりフレームワークだったり、gemだったりのバージョンアップが全体的にもっとスムーズに行えるようになると思います。

竹内: 聞いていて興味深いなと思いました。今回の発表というのは、コードを実行した際にDeprecatedになっていて、変更すべきコードを自動的に書き換えてくれるという認識でいいでしょうか?
深野: そうですね。コードの実行時に書き換わるというような想定なんですけれども、もちろんこの機能はproduction環境での実行というのは考慮されていないようです。test環境だったり、あとはローカルのdevelopment環境などでコードを実行して、その時に書き換えるというようなのを想定しているという話だったと思います。
竹内: ありがとうございます。もし完全にできたらかなり有益だと思うんですが、主な技術的な制限というのはどのようなものがあるのでしょうか?
深野: そうですね。例えば、まず書き換えのルールとして単純なメソッドの書き換えというのはすごく簡単ですよね。例えばRubyのURIでしたら、URI.regexpが非推奨になったので代わりにURI::DEFAULT_PARSER.make_regexpを使うみたいにするというのはすごく簡単だと思います。ただし代替策がないような場合、つまりこのメソッドが使えなくなりますが、これと同じような処理をする代替メソッドが提供されたりしていないみたいなケースでは書き換えルールを設定するのがとても難しそうですし、発表時点のRubyのDepreWriterでは実装されていなかったようです。
竹内: ありがとうございます。確かに代替策がない場合とかコンテキストによってどう書き換えるかが変わる場合というのはありそうなので、完璧に実装するのは難しそうだなというのは理解できました。

Matz Keynote

深野: ところで、今回のMatzさんのキーノートについてはどのような感想を持ちましたか?
竹内: そうですね、私が特に印象に残ったのは1番最初に話されていたReverse Alpha Syndromeの話ですね。AIが発展していくにつれて、人間はAIのために働くようになってしまうのではないかという懸念についてです。確かに注意していかないと、人間が下僕になってしまうというのは確かにその通りかもしれないと思いますね。最近のAIエージェントによるコーディングを見ても少しそうなってきている感覚はあるので、プログラミングの楽しさを見失わない、忘れないようにしていくのは1つ重要なことかなと思いました。小島さんは何か感想などありますか?
小島: そうですね。今竹内さんが言ってくださったのは僕も同感です。後はそうですね、正直僕はMatzさんがRubyコミュニティにおいて何をしているかそんなに詳しく知らなかったのですが、キーノートの内容を聞いて、あ、この人がRubyのコミュニティのトップなんだなっていう感じがすごくしました。Rubyの将来まで見据えられていて面白い内容でした。
深野: 私は型についての話が印象的でした。最近の静的型付け言語ブームでは開発生産性や安全性などよりも、実は1番理由として大きいのは開発者が型のパズル的な側面が楽しいからなんじゃないかという大胆な話だったのですが、自分は結構共感してしまいました。実際にrbsを触っていたりするのはかなり楽しいですし。MatzさんはRubyに言語機能としての静的型付けを組み込むことに反対されているとは思うのですが、静的型づけ言語を触る楽しさの部分を認めてくれたのはいいなって思いました。
竹内: 最後にもう1点いいでしょうか。他に印象深かったのが、Rubyコミッターと、会場に来ている、セッションを聞いている人たちがそれほど壁がないという発言でした。ちょっとした巡り合わせやエネルギーの向き方で周囲に与える影響も大きく変わってきますし、小さなことでもRubyコミュニティに貢献できたりはしますので、そうした垣根っていうのはそれほど大きくないんだなっていうのは、Matzさんのメッセージの中で感銘を受けた部分です。
深野: 確かにそこはすごく重要なメッセージでしたね。

RubyKaigi全体についての感想

深野: 最後にRubyKaigiに参加しての感想を一言ずつ述べていきましょう。
竹内: 今回がRubyKaigiの初参加でした。10年くらい前はRubyコミュニティの動向を追っていたんですが、最近は離れている状態でした。今回参加して忘れていたRubyの楽しさを思い出せたのが非常に良かった点だと思います。小島さんは何か感想ありますか?
小島: 僕はRubyにそんな詳しくなくてそれでも楽しめるのかなって少し不安だったのですが、先ほど紹介したima1zumiさんのキーノートの内容もRubyをそんな深く知らなくても楽しめる内容でしたし、そういう発表がいくつかありました。あとはやっぱりRubyのコミュニティに触れるいい機会になるというか、参加したことでRubyを勉強するモチベーションも上がったり、色々いい影響を受けることができたので参加して良かったなと思ってます。深野さんはどうですか?
深野: 今回のRubyKaigiの感想というか、去年のRubyKaigiに参加してからの1年の感想になってしまうんですけど、同じ人の発表を連続して聞き続けるとその人の興味の変遷だったり、OSSを公開している方だったらその成果物だったりを追いかけられて楽しいというところに気がつけました。具体的には、Rubyで実装されたWebアプリケーションの高速化について、去年は速度の計測について話していたのが今年はそこから発展して実際にSinatraを改造して速くするという発表をされていたosyouyuさんの発表などです。去年のRubyKaigi初参加もとても面白かったのですが、それとはまた異なるRubyKaigiを縦で追い続ける楽しみというのを味わうことができました。

今回は発表の話ばかりでしたが、各ブースでそれぞれの企業の事業内容や開発環境を聞くのも楽しめました。

おわりに

一緒に参加した弊社メンバーと講演についての感想を話してみることで、自分が聞けなかった講演についても興味を深めることができたり、自分が聞いていた講演についても改めて内容を咀嚼したりすることができました。
RubyKaigiのオーガナイザーの方々や、発表者の皆さんへの感謝でこの記事を締めたいと思います。
来年のRuby Kaigiも非常に楽しみです。

来年はついに北海道!