メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

環境ごとの設定管理が可能な ClinPeer のフィーチャーフラグの紹介

こんにちは。サーバーサイドエンジニアの佐藤太一(@teach_kaiju)です。

今回の「ClinPeerアプリ開発の裏側連載記事」ではサーバーサイドにおける、フィーチャーフラグの実装方法を紹介します。

tech.medpeer.co.jp

目次

フィーチャーフラグとは

フィーチャーフラグ(機能フラグ、別名フィーチャートグル)は、機能のオン・オフを制御する仕組みです。
ClinPeer ではフィーチャーフラグを用いることで、開発中の機能の細かいリリースや、社内IPのみ機能を有効にするなどの柔軟な制御を実現しています。

フィーチャーフラグに関する詳細は以下の記事に書かれています。こちらもぜひご覧ください。

tech.medpeer.co.jp

機能の有効化 Feature#enabled?

ClinPeer では Flipper gem をラップした Feature クラスを用いています。

基本的な使い方

特定の機能(例:allow_access_to_new_sugoi_feature)が有効かどうかを調べるには、以下のように記述します。

if Feature::ALLOW_ACCESS_TO_NEW_SUGOI_FEATURE.enabled?

特定の条件で有効化

enabled? メソッドには、オプションで引数を渡すことができます。この引数を使うことで、「特定のユーザーだけに機能を有効にする」「特定のIPアドレスからアクセスされた場合のみ機能を有効にする」といった、より細かい制御が可能になります。

例えば、特定のIPアドレスからのアクセスに対してのみ機能を有効にしたい場合は、そのIPアドレス文字列を渡します。

if Feature::ALLOW_ACCESS_TO_NEW_SUGOI_FEATURE.enabled?(request.remote_ip)

許可するIPアドレスは、Flipper UI で設定します。

flipper_ui_allow_ip

フラグの運用

フィーチャーフラグの定義や操作は、主に config/features.yml ファイルと Flipper UI を通じて行います。

features.yml

フィーチャーフラグの設定は、config/features.yml ファイルで一元管理されます。このファイルには、各フラグの識別子 (kind)、説明 (description)、そして環境ごとの設定を記述します。

# config/features.yml の例
- kind: allow_access_to_new_sugoi_feature
  description: 新しいスゴイ機能へのアクセスを許可する
  development: flipper
  test: true
  staging: flipper
  production: flipper

各項目の意味は以下の通りです。

  • kind: フラグの一意な識別子です。コード中では Feature::KIND_NAME のようにして参照できます。
  • description: フラグの説明です。この内容は Flipper UI のダッシュボードにも表示されます。
  • 環境名 (development, test, staging, production など):
    • flipper: Flipper UI でフラグの有効/無効を制御する場合に指定します。(デフォルト: 無効)
    • true: その環境では常にフラグを有効にします。
    • false: その環境では常にフラグを無効にします。

ClinPeer ではデプロイ時にseed を実行し、その中で features.yml の内容をもとに差分を更新します。

# app/models/feature.rb の抜粋

class Feature < ActiveYaml::Base
  include ActiveHash::Enum

  # 略
  
  set_root_path Rails.root.join("config")

  enum_accessor :kind

  scope :flipper_controllable, -> { where(Rails.env => FLIPPER_VALUE) }

  FLIPPER_VALUE = "flipper"
  private_constant :FLIPPER_VALUE

  # 略
end
# seed の処理

features = Feature.flipper_controllable.pluck(:kind) # ymlからFlipper制御対象のkindを取得
current_features = Flipper.features.map(&:name) # 現在Flipperに登録されている機能名を取得

# ymlにあってFlipperにないものを追加
(features - current_features).each { |f| Flipper.add(f) }
# Flipperにあってymlにないものを削除 (ymlから削除されたフラグ)
(current_features - features).each { |f| Flipper.remove(f) }

フラグの新規追加

config/features.yml に新しいフラグの定義を追加します。 デプロイ時に seed で features.yml の内容をもとに差分を更新します。

フラグの有効・無効の切り替え

config/features.ymlflipper と設定されているフラグの有効/無効は、Flipper UI (社内用管理画面) 上で操作します。

flipper_ui_on_off

フラグの削除

不要になったフィーチャーフラグを削除する際は、以下の手順で行います。

  1. フラグの参照箇所をコード上から削除
  2. 上記対応をリリース
  3. config/features.yml から該当フラグの定義を削除

ポイント
「フラグ参照箇所の削除」と「ymlからのフラグ定義の削除」を同一のリリースに含めないようにしています。

  • Flipper は、存在しないフラグを参照した場合、無効 (false) として扱われます。
  • ymlの変更(フラグ定義の削除)を反映するデプロイタスクは、アプリケーションコードの反映よりも先に実行される場合があります。

もし同一リリースに含めてしまうと、ymlからフラグが削除された後、まだ古いコードがそのフラグを参照しているわずかな時間帯に、意図せず機能が無効化されてしまう可能性があります。

条件付き有効化の実装

Flipper には対象をflipper_idで識別し、一致した場合のみ機能を有効化するという機能があります。 具体的には以下の2つを比較し、一致した場合機能を有効化します。

箇所
enabled? の第二引数 puts some_obj.flipper_id # 127.0.0.1
Flipper.enabled?("allow_access_to_new_sugoi_feature", some_obj)
Flipper UI で設定した actor
flipper_ui_allow_ip

(actor は flipper_id という識別子を持ったオブジェクト。この識別子を比較することで actor が同一であるかどうかを判断しています。そして、actor が同一であれば機能を有効化します。)

https://www.flippercloud.io/docs/features/actors

ClinPeerでは StringFlipperActor クラスを導入することで、任意の文字列を直接Actorの識別子として扱えるように拡張しています。

# app/models/feature.rb の抜粋

class Feature < ActiveYaml::Base

# 略

  class StringFlipperActor
    attr_reader :value

    def initialize(value)
      @value = value
    end

    alias flipper_id value
  end

# 略

  def enabled?(obj = nil)
    case value
    when FLIPPER_VALUE
      obj = StringFlipperActor.new(obj) if obj.is_a?(String)
      Flipper.enabled?(kind, obj)
    else
      !!value
    end
  end

  private

  def value
    public_send(Rails.env)
  end
end

これにより、Feature#enabled? メソッドに文字列を渡すと、その文字列がそのまま flipper_id として扱われます。

Feature::ALLOW_ACCESS_TO_NEW_SUGOI_FEATURE.enabled?("127.0.0.1") # "127.0.0.1" がflipper_idとなる

この仕組みを利用することで、IPアドレスや特定の識別文字列など、モデルオブジェクトが存在しないようなケースでも柔軟にActorベースのフラグ制御を行うことができます。

生成AIを活用したフラグ削除

フィーチャーフラグは、機能のリリースサイクルを柔軟にする強力なツールですが、役目を終えたフラグは適切に削除していく必要があります。フラグが増えすぎると、コードの複雑性が増し、管理コストも増大するためです。

従来、フラグの参照箇所の削除は以下の手順で行っていました。

  1. コードベース全体から、削除対象フラグの参照箇所を検索する。
  2. 特定された参照箇所を一つ一つ手動で修正・削除する。

このプロセスは、特に view 等の分岐が複雑な場合、時間と手間がかかり、見落としのリスクも伴いました。

そこで、現在はフラグの参照箇所の削除に生成AIを使用しています。プロンプトの例を以下に示します。

Feature::{フラグ名}は常に{true or false}なフラグとなりました。
上記を参照しているすべての条件分岐を削除してください。
features.ymlから対象のフラグの削除はしないでください。
features.ymlの該当のフラグに「TODO: 参照箇所削除済み、削除予定」というコメントを追加してください。
参考:
 - kind: allow_access_to_new_sugoi_feature # TODO: 参照箇所削除済み、削除予定
 
その後 features.ymlをコミットしてください
コミットメッセージ: Git履歴を残すために削除予定のコメント追加

AIを活用すると、手動と比較して、以下のようなメリットが見込めます。

  • 参照箇所の自動特定: AIがコードを解析し、削除対象のフィーチャーフラグが使用されている箇所を迅速に特定します。
  • 修正コードの提案: 特定された箇所に対して、AIが適切な修正案(フラグ参照の削除や、条件分岐の恒久化など)を提案してくれる場合があります。
  • 作業時間の短縮とミスの削減: 手作業による検索や修正と比較して、作業時間を大幅に短縮し、ヒューマンエラーによる見落としや修正ミスを減らすことができます。

最終的なコードの確認とテストは開発者自身が行う必要がありますが、AIツールを補助として利用することで、フィーチャーフラグのライフサイクル管理をよりスムーズかつ安全に行えるようになると考えています。

おわりに

本記事では、ClinPeerにおけるフィーチャーフラグの実装と運用方法について紹介しました。 Flipperという強力な基盤ライブラリを利用しつつ、Feature というActiveHashモデルでラップすることにより、アプリケーション固有の事情や、より使いやすいインターフェースを開発チームに提供しています。

このように、フィーチャーフラグシステムを適切に抽象化(ラップ)することには、多くのメリットがあります。

  • 管理の容易化: フラグの定義を一元化 (features.yml) し、環境ごとの挙動を明確にすることで、管理コストを低減します。
  • 利用の簡便化: Feature::KIND_NAME.enabled? のような直感的で統一されたインターフェースを提供することで、開発者が迷うことなくフラグを利用できます。
  • 将来的な拡張性: 例えば、将来的に別のフィーチャーフラグ管理システムに移行する場合でも、Feature クラス内部の実装を変更するだけで済み、アプリケーションコードへの影響を最小限に抑えることができます。
  • 独自のロジックの追加: StringFlipperActor のように、特定のニーズに合わせた独自のロジックを組み込みやすくなります。

フィーチャーフラグは、アジャイルな開発、安全な機能リリース、そしてA/Bテストなど、現代的なソフトウェア開発において非常に有効なプラクティスです。 ClinPeerでは、このような仕組みを活用し、ユーザーにより良い価値を迅速に届けられるよう、日々改善を続けています。

この記事が、フィーチャーフラグの導入や運用を検討されている方の一助となれば幸いです。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

GitHub Copilot を味方につける:AI に渡すコンテキスト整備の工夫

こんにちは。事業本部開発部 MISP グループのフロントエンドエンジニアの小林和弘(@kzhrk0430)です。

メドピアでは「AI ファーストカンパニー」を目指すことを全社で掲げています。実際に社内では、AI ツールを活用して業務を効率化する動きが活発に行われています。たとえば、Gemini を使って Google Meet の文字起こしや議事メモを作成したり、Notion AI で要件定義とテストケースの整合性を確認したり、スライド作成を AI に任せたりと、日々の業務に AI を積極的に取り入れています。

今回は、開発環境をより快適にするために GitHub Copilot(VS Code 拡張)を活用した取り組みをご紹介します。

AI に渡すコンテキストを整備する

Copilot は多様な機能を提供していますが、それぞれ異なる種類のコンテキストを参照している印象があります。そのため、意図通りに動作させるには、各機能ごとに適切なコンテキストを整備することが重要です。

今回は、以下の 3 点にフォーカスして取り組みました:

  • VS Code におけるコード生成
  • GitHub におけるコードレビュー支援
  • VS Code におけるコミットメッセージ自動生成

VS Code におけるコード生成

まず、コード生成機能に関しては、.github/copilot-instructions.md というファイルをプロジェクトに追加しました。このファイルには、プロジェクト特有の文脈や設計方針、命名規則などを記載しています。

作成時には、まず Copilot に初稿を書かせ、その内容を人間がレビュー・修正してブラッシュアップするという流れを取りました。なお、社内では AI ツールの Cline も利用しているため Cline 用にコンテキストを渡す.clinerules ファイルには .github/copilot-instructions.md を参照させるよう設定し、Cline 経由でも文脈が共有されるようにしています。

.clinerules の中身はこの用になっています。

# やくばとシステム開発ガイドライン

## 注意事項

このファイルは参照用として保持されていますが、最新かつ詳細な開発ガイドラインは `.github/copilot-instructions.md` に移動しました。
開発作業を行う際は、`.github/copilot-instructions.md` を参照してください。

GitHub Copilot をはじめとする開発ツールは、`.github/copilot-instructions.md` を参照してコード提案やガイドラインの適用を行います。

## リンク

開発ガイドラインの詳細は [.github/copilot-instructions.md](.github/copilot-instructions.md) を参照してください。

当初は .clinerules.github/copilot-instructions.md と同様に Cline に生成させていたのですが、コンテキストが二重管理になっていたため Copilot Agent に 2 つのファイルを統合させて .clinerules の中身を書き換えています。

GitHub におけるコードレビュー支援

GitHub の Pull Request テンプレートにも Copilot 活用の工夫を加えました。具体的には、Copilot のレビューがわかりやすくなるように、PR テンプレート内にレビューのコンテキストとなる情報を明記するようにしています。

今はまだレビューを日本語で書かせて、レビューコメントの表示がバグる HTML タグのコメントのルールを書いているだけですが、今後コンテキストを増やしてレビュー支援の質を高めて、より有用なフィードバックが得られるようにしたいと考えています。

<details>
<summary>このブロックは Copilot レビューのためのコンテキストです。Copilot は下記の命令を守ってください。</summary>

- レビューコメントは日本語で行う
- レビューコメントの HTML タグはマークダウンの Code spans (`) でラップする

</details>

GitHub 側でコンテキストを設定する機能は用意しているようですが、Copilot Enterprise プランでのみ利用でき、現在は一部のユーザーしか利用できない状態なので、今回の PR の説明文にコンテキストを注入する方法は一時的なハックになっています。

VS Code におけるコミットメッセージ自動生成

Copilot Chat 拡張機能のひとつに、コミットメッセージを自動生成してくれる機能があります。これに対しても、プロジェクトの文脈を反映させる設定を行いました。

具体的には、以下のように VS Code の settings.json.github/copilot-instructions.md を指定しています:

{
  "github.copilot.chat.commitMessageGeneration.instructions": [
    {
      "file": ".github/copilot-instructions.md"
    }
  ]
}

.github/copilot-instructions.md にはコミットメッセージにおけるルールを下記のように記載しています。

## 提案すべきコミットメッセージ

- コミットメッセージは日本語で書く
- git log で参照した過去のコミットメッセージを参考にする
- Conventional Commit を基本とする
- 1 行目のコミットの下には空白の行間を設ける
- 複数行の詳細なコミットメッセージを書く
  - 詳細なコミットメッセージは `## 背景``## 修正内容` などのマークダウンの見出しをつける

この設定により、コミットメッセージの生成時にもプロジェクト固有の背景が反映されるようになり、精度の高い出力が得られるようになりました。

Copilot で生成したコミットメッセージの一例

簡単なコミットメッセージであれば Copilot でコミットメッセージをジェネレートさせてさっとレビューするだけでコミットを作成しています。

VS Code における Copilot 設定の展望

VS Code の Copilot の instructions の設定は Experimental で提供されていますが、下記の 5 つの機能にコンテキスト設定ができるようになっています。

  • Review Selection: Instructions
  • Code Generation: Instructions
  • Commit Message Generation: Instructions
  • Pull Request Description Generation: Instructions
  • Test Generation: Instructions

VS Code の Copilot の設定画面のキャプチャー

GitHub 側も、.github/copilot-instructions.md にすべてのユースケースの情報を統合することが難しいと判断したのか、将来的には用途ごとにマークダウンファイルを分けることを検討しているのかもしれません。

AI に渡すコンテキスト整備の重要性

最近公開された Devin Wiki は、AI のコード理解能力を強く印象づけるものでした。

メドピアでも Devin AI を導入しており、社内で Devin Wiki の生成結果を確認する機会がありました。その中で、医療機関向けおよび薬局向けの Nuxt アプリを管理しているモノレポ構成のリポジトリに対して、Devin が自動的に、医療機関・薬局・患者間の処方せんの流れを示すフローチャートを生成していたのを目にし、大きな驚きがありました。

ただし、すべての機能について完璧に Wiki 化されているわけではなく、ハルシネーション(誤生成)が起きそうな領域については、あえてページを生成しないようにしているケースも見受けられました。

このように、AI ツールの進化によって、今後さらに多くの業務が AI によって支援・代替されるようになると感じています。これはエンジニアの仕事を奪うということではなく、むしろ課題発見力や判断力といった本質的な能力に集中できる環境が整っていく、というポジティブな変化だと考えています。

この点については、VPoE の保立さんも以下のインタビュー記事で言及しています: style.medpeer.co.jp

現時点では、AI がどこからどのようにコンテキストを取得しているのかを意識し、AI が誤解しないようなデータセット(明確な変数名や整理されたコード構造など)を整備することが、エンジニアに求められていると強く感じます。

おまけ:AI 活用と執筆の裏側

「AI に渡すコンテキストを整備する」セクションは、まず箇条書きで要点を整理し、それを ChatGPT に文章化してもらったうえで、内容を加筆・修正して仕上げました。

その他のセクションは、最初に自分で文章を書き、その後 ChatGPT にレビューを依頼して改善点を洗い出しました。

また、OGP 画像も記事をレビューさせたついでに ChatGPT に出力させています。

このように、試せるところから積極的に AI を活用し、自分なりに現在地を確認していくことが、AI 時代を前向きに生きる上で大切な姿勢だと考えています。

今後も、実務に即した形での AI 活用について、実験と発信を続けていきたいと思います。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

#RubyKaigi 2025 セッションレポート

皆様こんにちは、メドピアのサーバーサイドエンジニアの内藤(@naitoh)です。

RubyKaigi 2025に参加されていた皆さん、お疲れ様でした。

今回、内藤がRubyKaigi 本編に登壇しました。 発表内容の詳細は以下の記事にまとめておりますのでよろしければご覧ください。

naitoh.hatenablog.com

セッションレポート

RubyKaigi のセッションの中で特に印象に残ったセッションをご紹介します。 タイムテーブルは下記から確認ください。

rubykaigi.org

Make Parsers Compatible Using Automata Learning

rubykaigi.org

オートマトン理論は理解していなかったのですが、「オートマトンと正規表現は相互に変換できる」とのことなので、正規表現が数学的にオートマトンで表現できるのは美しく非常に良いですね!

RubyKaigi に来るたびに、自分の知らないコンピュータサイエンスの知識を思い知らされます。

聞きたいセッションがあれば、タイムテーブルに書かれている説明を予習しておくとセッションをもっと楽しめるんですよね。 続きものであれば、同じ人の昨年以前のセッション動画を見ておくのもお勧めです。

Goodbye fat gem 2025

rubykaigi.org

様々な gem をメンテナンスされている須藤さんの公演で、fat gem*1 の辛みを面白おかしく共感を呼ぶ形でお話されるトークでした。 自分のトークもこのように、会場の反応を楽しみながらできれば良いのですが、なかなかハードルが高いです。

公演の内容は、fat gem はユーザー視点だと利用するだけなら楽で良いけど、これって実は開発者側に多大なコストがかかっているので、持続可能性の意味で厳しいんですよね。 例えば nokogiri gem の場合、Ruby 3.4 がリリースされたその日に、対応する11プラットフォーム(内部的にサポート対象の Ruby のバージョンは4つ)が用意されています。ユーザーとしては非常にありがたいのですが、開発者目線で見ると nokogiri は頑張りすぎだと思います。 自分の gem でこんな事求められたら無理ですと断るレベル。

なので、須藤さんの提案は、

  1. C拡張 gem でもユーザー自身にビルドしてもらいましょう
  2. ビルド環境を用意するのが手間(よくビルドエラーになる)なので、ビルド環境を自動で準備できれば良さそう
  3. Windows 環境には RubyInstaller2 という先駆者がいる
    • Devkit というビルド環境がセット
    • パッケージマネージャもセット
    • 依存パッケージを自動インストール
    • 依存パッケージが存在せずインストールに失敗するということはない
  4. インストール時に自動で外部依存もインストールする rubygems-requirements-system gem を用意

このように、ユーザー自身でビルドする世界になれば持続可能性が高まり、みんなハッピーになるだろうということです。

ユーザーの環境で毎回ビルドのコストがかかる点と、ユーザーの環境に依存パッケージをインストールする必要がある点がデメリットですが、前者は許容できるコストで、後者はローカル環境を汚染したくない場合、Docker 上で実施するのが良いのではないでしょうか。(この gem がもし主流になれば、Dockerfile に依存パッケージ名を記載する手間がなくなる可能性もあるかもしれません。)

RuboCop: Modularity and AST Insights

rubykaigi.org

精力的に開発が続いている RuboCop のモジュール性のお話です。 これまで RuboCop は公式のプラグイン API を提供していなかったため、inject と呼ばれるモンキーパッチを利用する形で実現されており、それがデファクトスタンダードだったそうです。末恐ろしい状況ですね。

体系的なプラグインシステムが提供されるとユーザーとしても安心して使えるし、開発者としても貢献しやすくなりますよね!

また、RuboCop のバックエンドパーサーとして機能してきた Parser gem の代わりに、今後は Prism が採用されるとのことで、Ruby エコシステムの世代交代が進んでいますね。 Ruby の最新の機能が実用段階に来ているということで、どんどん最新版を使っていきましょう!

おわりに

3日間にわたる RubyKaigi 2025が終了しました。 非常に魅力的な公演が目白押しでしたが、3トラックのため、視聴できるセッションが限られていた点と、自身の発表の裏番組だった ZJIT を聞けなかったのが残念です。

次回のRubyKaigiは 2026年4月22日から4月24日、場所は北海道函館市です。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

*1:事前にビルド(コンパイル)された C拡張バイナリを同梱した gem。新しい Ruby が出るとその Ruby のバージョンにあわせた対応バイナリが必要。

ScrollableTabRowでスクロール状態を監視する

はじめに

こんにちは!メドピアにてモバイルアプリエンジニアをしている佐藤です。
今回の「ClinPeerアプリ開発の裏側連載記事」では、ScrollableTabRowでのスクロール状態の監視方法について解説していきたいと思います。 tech.medpeer.co.jp

背景

ClinPeerアプリでは、ユーザーが関心のあるカテゴリーをタブとして動的に追加できるようにしており、追加したカテゴリーを上部のタブで表示しています。その際、右側にまだ表示しきれていないタブがあることをユーザーに伝えるため、右端にスクロール可能な場合はアイコンを表示しています。

左:スクロールできるときは赤枠内のアイコンを表示
右:スクロールできない場合はアイコンを非表示

XMLレイアウトのTabLayout + ViewPager2であればaddOnScrollChangedListenerで監視出来るので、Jetpack Composeでも rememberScrollState などを使えば簡単に実装できると思っていましたが、実際には少しハマりどころがありました。
そのため、今回はその解決方法を共有します。

結論

PrimaryScrollableTabRow を使うことで解決できます!
androidx.compose.material3:material3-*:1.2.0-alpha09」のリリースでスクロール状態が公開されるようになりましたので、本バージョン以降のPrimaryScrollableTabRowを用いれば簡単に監視が出来るようになります。

PrimaryScrollableTabRow は執筆時点ではまだ試験運用版で @OptIn(ExperimentalMaterial3Api::class) のアノテーション付与が必要です。
将来的に仕様が変更される可能性もあるため、バージョンアップ時の挙動に注意しましょう。

PrimaryScrollableTabRowについて

ScrollableTabRowとPrimaryScrollableTabRowの定義を比較してみます。(定義のandroidx.compose.material3:material3のバージョンは1.3.1のものです)

@Composable
fun ScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    containerColor: Color = TabRowDefaults.primaryContainerColor,
    contentColor: Color = TabRowDefaults.primaryContentColor,
    edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding,
    indicator: @Composable (tabPositions: List<TabPosition>) -> Unit =
        @Composable { tabPositions ->
            TabRowDefaults.SecondaryIndicator(
                Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
            )
        },
    divider: @Composable () -> Unit = @Composable { HorizontalDivider() },
    tabs: @Composable () -> Unit
)
@ExperimentalMaterial3Api
@Composable
fun PrimaryScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    scrollState: ScrollState = rememberScrollState(),
    containerColor: Color = TabRowDefaults.primaryContainerColor,
    contentColor: Color = TabRowDefaults.primaryContentColor,
    edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding,
    indicator: @Composable TabIndicatorScope.() -> Unit =
        @Composable {
            TabRowDefaults.PrimaryIndicator(
                Modifier.tabIndicatorOffset(selectedTabIndex, matchContentSize = true),
                width = Dp.Unspecified,
            )
        },
    divider: @Composable () -> Unit = @Composable { HorizontalDivider() },
    tabs: @Composable () -> Unit
)

ScrollableTabRowではscrollStateを指定することは出来ませんが、PrimaryScrollableTabRowではscrollStateのパラメーターが追加されているので、scrollStateにrememberScrollStateを指定してスクロール状態を監視出来るようにします。

解説

これらの内容を踏まえた上で実装例を書きます。
まず、スクロール状態の監視が行えないScrollableTabRowのコードが下記の通りです。

Scaffold(
    modifier = Modifier.fillMaxSize(),
    topBar = {
        CenterAlignedTopAppBar(
            title = {
                Text(
                    "ScrollableTabRowExample",
                    fontSize = 18.sp,
                    textAlign = TextAlign.Center
                )
            }
        )
    },
    content = { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
             val items = listOf(
                "Tab 1",
                "Tab 2",
                "Tab 3",
                "Tab 4",
                "Tab 5",
                "Tab 6",
                "Tab 7",
                "Tab 8",
                "Tab 9",
                "Tab 10"
            )
            val pagerState =
                rememberPagerState(pageCount = { items.size })
            val scope = rememberCoroutineScope()

            ScrollableTabRow(
                selectedTabIndex = pagerState.currentPage,
                edgePadding = 0.dp
            ) {
                items.forEachIndexed { index, tab ->
                    Tab(
                        selected = pagerState.currentPage == index,
                        onClick = {
                            scope.launch {
                                pagerState.animateScrollToPage(index)
                            }
                        },
                        modifier = Modifier.height(48.dp)
                    ) {
                        Text(tab)
                    }
                }
            }
            HorizontalPager(
                state = pagerState
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        "page: ${items[it]}"
                    )
                }
            }
        }
    }
)

スクロール状態の監視を行なっていない画面

右側にスクロールが出来る状態である場合にアイコンを表示するといったことをしたい場合、以下の通り変更します。

Scaffold(
    modifier = Modifier.fillMaxSize(),
    topBar = {
        CenterAlignedTopAppBar(
            title = {
                Text(
                    "PrimaryScrollableTabRowExample",
                    fontSize = 18.sp,
                    textAlign = TextAlign.Center
                )
            }
        )
    },
    content = { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
             val items = listOf(
                "Tab 1",
                "Tab 2",
                "Tab 3",
                "Tab 4",
                "Tab 5",
                "Tab 6",
                "Tab 7",
                "Tab 8",
                "Tab 9",
                "Tab 10"
            )
            val pagerState =
                rememberPagerState(pageCount = { items.size })
            val scope = rememberCoroutineScope()
            val scrollState = rememberScrollState()

            // スクロール可能なアイコンの表示状態を管理するフラグを追加する
            var showArrow by remember { mutableStateOf(false) }
            LaunchedEffect(scrollState.maxValue, scrollState.value) {
                // 右側にスクロール可能な状態の場合はshowArrowをtrueにする
                showArrow = scrollState.value < scrollState.maxValue
            }

            Box {
                PrimaryScrollableTabRow(
                    selectedTabIndex = pagerState.currentPage,
                    edgePadding = 0.dp,
                    scrollState = scrollState
                ) {
                    items.forEachIndexed { index, tab ->
                        Tab(
                            selected = pagerState.currentPage == index,
                            onClick = {
                                scope.launch {
                                    pagerState.animateScrollToPage(index)
                                }
                            },
                            modifier = Modifier.height(48.dp)
                        ) {
                            Text(tab)
                        }
                    }
                }
                if (showArrow) {
                    // 右側にスクロール可能な状態の場合は右端にアイコンを表示する
                    Box(
                        modifier = Modifier
                            .width(44.dp)
                            .height(48.dp)
                            .background(
                                Brush.linearGradient(
                                    colors = listOf(
                                        Color.White.copy(0f),
                                        Color.White.copy(1f)
                                    )
                                )
                            )
                            .align(Alignment.CenterEnd)
                    ) {
                        Image(
                            imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
                            contentDescription = null,
                            modifier = Modifier.align(Alignment.Center)
                        )
                    }
                }
            }
            HorizontalPager(
                state = pagerState
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Text(
                        "page: ${items[it]}"
                    )
                }
            }
        }
    }
)

スクロール状態を監視して右側にスクロール出来る時はアイコンを表示

最後に

PrimaryScrollableTabRowに関する記事がほとんどなかった為、この場を借りて紹介させていただきました。
PrimaryScrollableTabRowを使うことで、スクロール状態を監視してユーザー体験を向上させるような細かなUI調整も可能になります。
本記事が同じような課題で悩んでいる方や今後同じような機能を実装する方の助けになれば幸いです!


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

RubyKaigi 2025 に@naitoh が登壇します #rubykaigi

皆様こんにちは、メドピアのサーバーサイドエンジニアの内藤(@naitoh)です。

この度、2025/04/16(水)-18(金)の3日間で開催される「RubyKaigi 2025」に登壇させていただくこととなりました! タイトルは「Improvement of REXML and speed up using StringScanner」となります。

rubykaigi.org

スケジュールは、 Day2 11:50 〜 12:20 / Pearls Room を予定しています。 ぜひセッションにお越しください。

登壇内容について

セッションでは以下の内容をお伝えします。

REXML is a standard XML library (Bundled Gem) for Ruby implemented in Pure Ruby. It is up to 40% faster between rexml 3.2.6 gem attached to Ruby 3.3.0 and rexml 3.4.0 gem attached to Ruby 3.4.0. Through our REXML speedup efforts using StringScanner, I will explain why using StringScanner is faster and how it can be implemented to make it faster.

昨年の RubyKaigi 2024 のLT でお話した話

の続きで、 REXML のXMLパース処理を StringScanner を使ってREXML 3.2.6 (Ruby 3.3.0 添付のバージョン) からREXML 3.4.0 (Ruby 3.4.0 添付のバージョン) の間で約4割速くしたので、StringScanner を使うと何故パース処理が速くなるのか、どのような点に気をつけてパース処理を書けば速くなるのかなど、高速化のポイントを皆様に紹介します。

おわりに

それでは皆様、当日お会いできることを楽しみにしております!


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

Railsの「ActiveSupport::ErrorReporter」って知ってる?

こんにちは。サーバーサイドエンジニアの三村(@t_mimura39)です。

またまた「ClinPeerアプリ開発の裏側連載記事」です。 tech.medpeer.co.jp

今回はClinPeerで活用しているRailsの ActiveSupport::ErrorReporter についてご紹介します。

目次

ActiveSupport::ErrorReporter とは

Railsに標準添付されているエラー管理の仕組みです。

↓これが

begin
  do_something
rescue SomethingIsBroken => error
  MyErrorReportingService.notify(error)
end

↓こうなります。

Rails.error.handle(SomethingIsBroken) do
  do_something
end

詳細はRails公式ドキュメントをご参照ください。

guides.rubyonrails.org

以上になります。ありがとうございました。

と、終わるわけにもいかないのでもう少し深い話を書きます。

なぜ ActiveSupport::ErrorReporter を使うのか

上述の通り典型的な例外ハンドリング処理に対して統一的なI/Fを提供してくれるのですが、それ以外にもいくつかの利点があります。

まず一つ目はPubSub的な仕組みになっているため「例外発生時に実行したい処理」の増減に対して柔軟に対応可能という点です。

ClinPeerでは例外発生時に以下2種類の処理を実行しています。

  • Rollbarへの例外通知
    • エラー管理サービスとして利用しているRollbarに例外情報を通知します。他SaaSを利用しているプロジェクトは良いように読み替えてください。
  • ログ出力
    • 上記のようなエラー管理サービスが不調な場合でも例外状況を把握できるようにログも出力しています。

例えば以下のように初期設定をしてみます。

# config/initializers/rails_error_subscriber.rb
class RailsLoggerErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    error_message = error.message
    message = "#{error.class}: #{error_message}\n#{(error.backtrace || caller)&.join("\n")}"
    severity = :warn if severity == :warning
    Rails.logger.public_send(severity, message)
  end
end

class RollbarErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    extra = context.is_a?(Hash) ? context.deep_dup : {}
    Rollbar.log(severity, error, extra)
  end
end

Rails.application.config.after_initialize do
  Rails.error.subscribe(RailsLoggerErrorSubscriber.new)
  Rails.error.subscribe(RollbarErrorSubscriber.new)
end

Rails.error.reportRails.error.handle で例外を処理する際に、登録したサブスクライバー全てに例外情報を通知することができます。

# これを
begin
  do_something
rescue SomethingIsBroken => error
  Rails.logger.error("#{error.class}: #{error_message}\n#{(error.backtrace || caller)&.join("\n")}")
  Rollbar.log(:error, error)
end

# こう書き換えられて
begin
  do_something
rescue SomethingIsBroken => error
  Rails.error.report(error)
end

# こう書くこともできる
Rails.error.handle(SomethingIsBroken) do
  do_something
end

この仕組みは「Rollbarから別のサービスに乗り換えるケース」でもとても役立ちます。そうしたケースでもRailsアプリケーション内の例外ハンドリング処理に手を加えずにinitializerの中身を変更するだけで対応が可能になります。

実行コンテキストの注入

どのリクエスト・ジョブで発生した例外なのかを表す「実行コンテキスト」情報がエラー通知には付与されて欲しいものです。それを便利に取り扱うための仕組みが ActiveSupport::ErrorReporter には用意されています。
各サブスクライバーに定義するreportメソッドの引数には context というものがあります。

def report(error, handled:, severity:, context:, source: nil)

本連載記事を購読してくださっている方はもうお気づきかもしれません。
はい、この context の実体は ActiveSupport::ExecutionContext です。

https://github.com/rails/rails/blob/v8.0.2/activesupport/lib/active_support/error_reporter.rb#L224

ActiveSupport::ExecutionContext の詳細については以下の記事をご参照ください。

ClinPeer Railsプロジェクトのオブザーバビリティ強化施策#実行コンテキスト

かなり掻い摘んで説明すると、 context にはリクエストやジョブが実行されているActionControllerやActiveJobのインスタンスが格納されています。

ClinPeerではこのようなSubscriberを定義することで、Rollbarへの全てのエラー通知に自動的に実行コンテキスト情報が付与されるようにしています。

class RollbarErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil) # rubocop:disable Lint/UnusedMethodArgument
    extra = context.is_a?(Hash) ? context.deep_dup : {}

    controller = extra[:controller]

    extract_context!(extra)

    extra[:custom_data_method_context] = source

    scope = { request: controller&.rollbar_request_data, person: controller&.rollbar_person_data }
    Rollbar.scoped(scope) { Rollbar.log(severity, error, extra) }
  end

  private

  def extract_context!(context)
    # 現在実行されているコントローラまたはジョブの情報が設定されている
    # https://github.com/rails/rails/blob/v8.0.2/actionpack/lib/action_controller/metal/instrumentation.rb#L60
    # https://github.com/rails/rails/blob/v8.0.2/activejob/lib/active_job/execution.rb#L66
    controler_or_job = context.delete(:controller) || context.delete(:job)
    return unless controler_or_job.present? && controler_or_job.respond_to?(:_execution_context)

    context.reverse_merge!(controler_or_job._execution_context)
  end
end

なぜ ActiveSupport::ErrorReporter を使うのか(本当のメリット)

「PubSubな仕組みの便利さ」「実行コンテキスト注入の仕組み」について説明しましたが、実はこの程度であれば十分に自前で実装することが可能です。
その2点よりも遥かに大きな強みとして私が考えるのは「Railsが提供しているI/F」という点です。

この Rails.error.report というI/FがRails公式で提供されているため、Rails内やフレームワーク的なGemでの例外処理にデフォルトで組み込まれやすくなります。

実際にRails内でも何箇所か ActiveSupport::ErrorReporter が利用されています。

Rails内での利用例

v8.0.2時点

これを執筆している今現在も ActiveSupport::ErrorReporter の活用が進んでいます。
以下は2025年4月時点でのmainに取り込まれているPRです。

Rails以外の利用例

などのRails以外のGemでも ActiveSupport::ErrorReporter が利用が進んでいます。

また、ClinPeerでは自前でRollbarのSubscriberを定義していますが、 RollbarSentry が公式でSubscriberを定義していたりします。 実行コンテキスト周りにこだわる必要がなければそれらのGemを導入するだけでほどほどに例外通知される状態になります。

各フレームワーク層で ActiveSupport::ErrorReporter を活用した例外通知を実装してくれることで、アプリケーション内での例外補足を一定サボれるだけでなく、これまで考慮外にあった例外なんかを漏れなく補足することもできるようになり嬉しいですね。

おわり

偉そうに ActiveSupport::ErrorReporter について語りましたが、実はClinPeerの開発を始めるまで存在も知りませんでした(Railsの更新はマメにウォッチしているつもりなのですが)。まだまだRailsには伸び代があり、痒い所に手が届く感じが気持ちが良いですね。

既存のRailsシステムの例外ハンドリング処理に手を加えるのは大変ですが、こういった所でも小まめにRails Wayに乗っておくと将来の技術的負債の解消に繋がるので導入を検討してはいかがでしょうか。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp

技術顧問Matzとは、どんな話をしているの?

こんにちは、組織開発グループの榎本です。

弊社の技術顧問にはまつもとゆきひろ(通称・Matz)さんがおり、Matzさん(以下、親しみも込めてMatzと表記します)とは定期的に「Matz会」と称して、Google Meetを繋いでリモートミーティングを開催しています。

Matz会のテーマは多岐にわたりますが、「毎回Matzとどんな話をしているの?」と気になっている開発者の方もいるかもしれません。本記事では、過去のMatz会開催実績を元に、その内容の一部をお伝えできればと思います。

講演の再演

過去のテーマ例:

  • RubyKaigi Keynote の再演
  • Matzが登壇したイベントの講演の再演
  • Matzチャンネルで話していたトピックの再演

一番わかりやすいのはこちらでしょうか。全員が全員、RubyKaigi現地に足を運んで、生Matzのキーノートを聴けるわけではないので、RubyKaigi閉会後すぐにMatzに再演してもらえるのは良い機会となりました。

tech.medpeer.co.jp 👆️RubyKaigi閉会後に行った感想戦の様子

過去に好評だった会でいうと、「動的型付け言語と大規模開発」の再演は、Matzの型に対する考え方を深く聞くことができました。

Rubyの最新機能の解説

過去のテーマ例:

  • YJIT
  • Prism
  • 次期Rubyバージョン解説

最新バージョンのRubyで導入される技術について、Matzに解説をしてもらいました。Rubyのリリースノートだけでは知ることのできない導入経緯(なぜその機能が入ったか、どんな課題を解決したいのか)や裏エピソード(Rubyコアチーム内でどんなコミュニケーションがあったのか)などが聞けて良かったです。

Rubyのコア技術の解説

過去のテーマ例:

  • YARV について
  • Ruby GVL について
  • Ruby GC の仕組み

このあたりの話を完全に理解するには、コンピュータサイエンスの知識も必要になってくるので、なかなか難しいテーマではありました。しかしエンジニアの知的好奇心を刺激する良いテーマだったと感じています。ときに話題はCRubyの内部実装に及ぶこともあり、Rubyエンジニアにとっては話についていくのでイッパイイッパイ、という人も多かったようです。

OSS

過去のテーマ例:

  • OSSへの貢献方法
  • Rubyのコミュニティ運営について
  • Rubyの新機能の意思決定について

OSS貢献未経験のエンジニアから「OSSに貢献したいけど貢献の仕方がわからない...」「OSSに貢献したいけどどうしたらいいの?」という声があり、それに対してMatzからいろいろアドバイスを貰えたのは良い機会でした。今後社内からOSS貢献するエンジニアが増えていくといいなと考えています。

Rubyのコミュニティ運営についても伺いました。個人的にあまり表に出てこないRubyの意思決定(新機能のAccept、あるいは提案のReject)の裏事情的な話が聞けたのが良かったと感じています。Rubyの進化の裏にあるMatzの様々な葛藤を垣間見ることによって、Rubyというプログラミング言語の存在の有り難みを改めて感じることができました。

キャリア論

過去のテーマ例:

  • エンジニア・キャリア戦略
  • AIと開発者の今後

Matzの考えるキャリア戦略について語ってもらいました。キャリアに関しては若手からシニアまで悩む開発者が多いと思うので、キャリアに迷う開発者にとって参考になる話だったと思います。

昨今のAIトレンドも取り入れて「AIと開発者の今後ってどうなると思う?」みたいなテーマについても語っていただきました。

Matzへプレゼン

過去のテーマ例:

  • 弊社メンバーが作成した LTをプレゼン
  • 弊社の事業紹介をプレゼン

弊社メンバーが過去に行った発表内容や弊社の展開している事業について Matz にプレゼンテーションを行う機会を設けました。Matzから内容について直接フィードバックいただける良い機会になりました。

tech.medpeer.co.jp 👆️MatzにRubyKaigi関連LTをプレゼンする様子

その他

過去のテーマ例:

  • Matzの開発環境について
  • Matzに何でも質問コーナー
  • mrubyについて

エンジニアとしては、Matzが普段使っているPC、OS、キーボード、デスク環境など気になる方も多いのではないかと思います。そのあたりの開発環境について掘り下げさせてもらいました。

またMatz会では毎回最後に「Matzに何でも質問コーナー」のような質問時間を設けています。この時間では時間の許す限り、弊社メンバーから思い思いのMatzに聞きたいことをぶつけています。生Matzをカンファレンスで見かけることはあっても、時間をとってもらって直接質問する機会はなかなか訪れないので、メンバーにとっては嬉しい機会になっていると感じます。

そしてMatzさんは月一でWeb会議を開いて頂いているので、そこで毎月疑問などをぶつけられます。(中略)こう言った著名な技術顧問の方がいるおかげで会社全体の技術力の底上げになっていると思います。 Railsの実装やRuby内部のことが気になった際にすぐに質問できるという環境はエンジニアにとってものすごく良い環境だと思っています。

tech.medpeer.co.jp

さいごに

いかがでしたでしょうか。

本記事で紹介したテーマは、Matz会運営チームが中心となって決めています。運営チームとしては、Rubyのコアな技術話からキャリアや開発環境などのカジュアルなテーマまで、幅広い開発者に楽しんでもらえるようなテーマ設定を心がけています。こうすることでジュニアレベルのエンジニアからシニアレベルのエンジニアまで、またRubyエンジニアから普段Rubyを使わないエンジニアまで、楽しんでもらえるテーマ設定になっていると思います。

本記事で紹介したトピックをMatzから直接聴きたい!という方がいらっしゃれば、ぜひ弊社への入社をご検討いただければと思います!


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp