メインコンテンツまでスキップ

· 約14分
中川 優

初めまして、中川優(すぐる)と申します!サービス開発部/プロダクト開発部門/従業員サーベイチームのプロダクトエンジニアです!

仕事で使用している主な言語はRuby・TypeScriptです。個人的にGolangあたりを最近触ったりしています。趣味はヘヴィメタルです。

今回は、当社プロダクトの一つであるミキワメウェルビーイングサーベイが過去に直面した、閲覧範囲制御機能のパフォーマンス上の課題、および改善に至るまでの事例をご紹介いたします。

ミキワメウェルビーイングサーベイとは?

心理学・行動科学の知見に基づき従業員のウェルビーイング(幸福度)を測定・分析する、実名性の”パルスサーベイ”ツールです。

  • 短時間の回答(3分間)によって、即座に回答結果・アドバイスを確認できる
  • 個人の性格に基づいた、ウェルビーイング向上のためのアドバイスを提供
  • 組織全体の回答結果を可視化し、組織単位でのアドバイスも受けることができる

詳しくは下記もご覧ください。

『ミキワメ ウェルビーイングサーベイ』とは?社員の幸福度を高め離職・休職を防ぐ|ミキワメラボ

閲覧範囲制御機能とは?

ミキワメでは大きく分けて2つの画面を提供しています。

  1. 従業員向け画面:従業員が自身の回答結果を閲覧するためのページ
  2. 企業向け管理画面:企業担当者が組織・各従業員の回答結果を閲覧するためのページ

閲覧範囲制御は、この内の企業向け管理画面における新機能として、2024年5月30日にリリースされたアップデートです。

アップデート前

企業向け管理画面のログインユーザーでは複数の権限が存在し、その中に”マネージャー”という権限があります。マネージャーは、特定の部署・および部署に所属する従業員のサーベイ回答結果のみを閲覧することができます。 しかし、そのマネージャーが現実でその役職に任命以前の結果まで閲覧できてしまう状態でした。

  • 2024年6月1日にサービス開発部のマネージャーに任命されたAさんは、任命されるより以前のサービス開発部のサーベイ回答結果も閲覧できてしまう

ミキワメウェルビーイングサーベイでは、従業員が自由記述できるコメント機能、企業担当者が従業員に対する打ち手を記録するサポート履歴機能などもあります。 その為、誰かに見られては困る内容を、その誰かが閲覧できてしまうリスクがありました。

アップデート後

マネージャーが特定の部署のみ回答結果を閲覧することができるという条件に加え、その部署を閲覧権限を付与した以前の結果は、閲覧不可とする制御ができるようになりました。

  • 2024年6月1日にサービス開発部のマネージャーに任命されたAさんは、任命されるより以前のサービス開発部のサーベイの回答結果は閲覧することができない

これによって、アップデート前のリスクを防止することができ、より安心して利用できる仕様にすることはできました。

ところが、このアップデートのリリース直後、とある問題が起きてしまいます・・・

画面の表示速度がめっちゃ遅くなっている!!!

それは、サービス開発部で毎月末に実施している、各チームの進捗報告などの定例の中での話でした(確かその初回だったと記憶)。

EM「サーベイで画面表示速度が遅くなっているらしいので、従業員サーベイチームは調査に入ってください」

わたし「!!?」

企業向け管理画面では、全社・部署単位で組織のサーベイ回答結果を閲覧できるページがあり、ミキワメウェルビーイングサーベイにおけるコア機能の一つなのですが、このページにおいて著しいパフォーマンス低下が発覚。

例:数千人分の回答結果を持つページの場合

  • 初期表示までに10~20秒、あるいはそれ以上かかってしまう
  • サーベイに回答した社員一覧データを取得するための非同期処理1~3分以上かかってしまう

当時はエンタープライズ企業への導入が活発化し始めていた状況だったのですが、その矢先にこのような事態に見舞われてしまったのです・・・!!

ボトルネックを探し、仮説を立てる

この事態を受け、従業員サーベイチームではリードエンジニアの力を借りながら、とにかくやれることをやっていくこととしました。

  • 不足しているeager loadingの追加
  • メモ化できていない所のメモ化
  • 画面表示の都度発生していた計算処理を、DBから参照する形に修正
  • さらに非同期取得してよさそうな所があれば非同期化
  • etc

このあたりは一定改善があったのですが、元の速度に戻るまでには至らず。そして気づけば1ヶ月経過・・・。別の観点でも調査を継続していくこととなりました。

遅くなっている画面でrack-mini-profilerを用いたクエリ発行数の調査をしていく中で、多くのクエリが発行している箇所を確認しました。この箇所では、サーベイの回答結果が閲覧できるかどうかを以下のように制御しており、これが速度低下に影響しているのではないか?という仮説を立てることとしました。

  • 回答者がサーベイに回答した日時の時点で所属していた部署が、マネージャーの閲覧可能な部署である

一人一人回答タイミングは異なることから、配信対象になった社員全員分を対象をループさせ、回答者ごとに都度閲覧可能かを判定する必要があります。

# コードはイメージ。実際のものは異なります。

# cq_recruitment_examinees: サーベイ配信対象者になった社員
# supervisor: 企業向け管理画面のログインユーザー。ここではマネージャーを想定
# division: 部署

cq_recruitment_examinees.select do |cq_recruitment_examinee|
# 社員がサーベイに回答した日を取得するメソッド
deadline = cq_recruitment_examinee.completed_time

# 部署IDと回答日を渡してマネージャーが閲覧可能かを判定
# 内部で閲覧可否判定用のデータを取得するクエリの発行をしている
supervisor.survey_permission?(deadline, division.id)
end
  • ループごとにクエリが発行されN+1が発生
  • 社員が多いほど処理時間が線型に増加する

上記のコード例は説明のために簡略化させていますが、もう少し色々処理が伴っていたり、類似した処理が複数箇所で実装されていたりします。これらが速度低下のボトルネックなのでは!?という話になったのです。

解決に向けたアプローチ

一定ボトルネックが明確になったので、下記の改善案を考案する形になりました。

  • 配信されたサーベイの配信終了日の時点で回答者が所属していた部署が、マネージャーの閲覧可能な部署である

これにより、全社員に対して同一の時点で閲覧可否を判断することができます。

# コードはイメージ。実際のものとは異なります。

# cq_recruitment: 配信されたサーベイ。cq_recruitment->cq_recruitment_examineesで1対多のリレーション。
# サーベイが終了した日付を取得するメソッド
deadline = cq_recruitment.deadline
CqRecruitmentExaminee.none if supervisor.survey_permission?(deadline, division.id)

cq_recruitment_examinees

ボトルネックとなっていたループ処理が不要になり、回答者人数に依存することなく制御できるようになりました。 ただし元々の仕様自体を変えることになるので、PdM・カスタマーサクセスのメンバーとの合意を得る必要がありましたが、一定議論を重ね、パフォーマンス以外のUX改善も盛り込む形で上記の対策を取る方針で合意することができました!

まとめ

閲覧範囲制御の実装範囲は広く、実装自体にも約2ヶ月弱かかりましたが、概ね狙い通りにパフォーマンスを改善!

beforeafter
初期表示期間10〜20秒(または数分以上)1秒未満
一覧データ取得時間(非同期)1〜3分以上数秒〜15秒程度

これで、エンタープライズ企業への導入の障壁を大きく下げることができました。

このアップデートのリリース時点で2024年9月半ば。3ヶ月半の長い道のりで、この間メンバーの入れ替わりなどもありましたが、無事にここまでやり遂げられたことは今でも貴重な経験だったなと思っております。特に下記は教訓となりました。

  • 大規模データを想定して、アップデート前後で速度の低下が発生していないかを検証した上で実装すること。非機能要件の重要性を再認識した。
  • 時にはビジネスロジックの再考も話し合える姿勢を持つこと。役割を超えてより良い開発をする機会となった。

ネクストアクション

非同期処理の中には、まだ社員がサーベイに回答した日起点で閲覧可否を制御せざるを得ないパターンがあり、10〜15秒程度かかる場合があります。 こちらも更に速度が出るように改善し、より快適なサービスにしていきたいです。

そして、実は今回の取り上げた事例以外にも表示速度が遅いページが残っています・・・!同じく企業向け管理画面にある、企業上に作成された部署の一覧表示するページです。これもエンタープライズ企業にて顕在化した課題であり、同様にパフォーマンスチューニングが必要な状況です。こちらにも積極的に関わっていきたいなと思っております。

以上です。最後までご覧いただきありがとうございました!

· 約5分
篠倉 遼

初めまして、サービス開発部プロダクトエンジニアの篠倉と申します。

2024年12月、弊社プロダクト『ミキワメ』で新機能をリリースしました。

この機能の開発では、20テーブル以上のデータベース設計を行う必要がありました。大規模なER図を効率的に作成し、レビューをスムーズに進めるために試行錯誤した手法と得られた知見を共有します。

直面した課題

今回のER図設計では、以下の3つの要件を満たすことが課題となりました。

  1. テーブル名やリレーションを明確に表現する
  2. カラム情報(型、NULL可否、外部キー等)を適切に記載する
  3. 効果的なレビューを実現し、スムーズにフィードバックを受けられる環境を整備する

検討過程と採用した方法

最初にNotionとMermaidの組み合わせを検討しましたが、以下の2つの理由で採用を見送りました。

※Mermaidとはマークダウンに近い書き方でフローチャート、シーケンス図、ER図を書ける記法です

  • コメントがコード上にしか書けず、レビューが非効率になる

image.png

  • リレーションを詳細に記載すると文字が小さくなり、視認性が低下する

image.png

検討を重ねた結果、PlantUMLを用い以下の方法を採用しました

※PlantUMLとはオープンソースのツールです。Mermaidと同様フローチャート、シーケンス図、ER図を書くことができます。

  1. PlantUMLを使用してER図を作成
  2. 作成したER図をMiroに添付
  3. Miro上でチームメンバーがコメントを付与

この方法により、大規模なER図に対する効果的なチームレビューが可能になりました。

image.png

感じた利点と課題

  • 利点
    • テーブル名、リレーション、カラム情報を一元管理できる
    • Miro上で拡大縮小ができ、全体像と詳細の両方を確認しやすい
    • ER図上に直接コメントを残せるため、レビューがスムーズになる
  • 課題
    • 大規模なER図では複雑になり、視認性が低下する
    • 図を修正するとコメントが消えるため、やり取りのログが残らない

今回のER図作成を振り返ってみて

今回は上記の「直面した課題」の解決を実現するため、PlantUMLとMiroを用いた方法を使ってER図の作成とレビューでテーブル設計を行いました。課題はありつつも一定解決できたと感じました。

しかし、仮にまた同様のER図作成を行う場合は以下の方法を試したいと考えています。

  1. Notion + Mermaidで全体のER図を作成し、シンプルにリレーションを表現する
  2. 機能ごとに詳細なER図とテーブルの説明をNotionにまとめる

このような記載方法のメリットとしては全体像と詳細を分けて表現できる点です。全体ER図で構造を把握しやすくし、機能別の詳細ページで深い理解が可能になります。

まとめ

大規模なデータベース設計では、ER図の作成とレビューの方法が開発のスムーズさを左右します。今回採用したPlantUML + Miroの手法は、一定の成果を得られましたが、より柔軟な管理を実現するための改善点も見つかりました。

同様にER図の作成とレビューの方法についての課題に直面している方は、自社の開発環境に応じて最適な手法を検討してみてください。

· 約19分
梶原 悠司

こんにちは。リーディングマークのミキワメ適性検査開発チーム、エンジニアリングマネージャーの梶原です。
本稿では、2023年末から2024年初頭にかけて実施した「受検者データ分離プロジェクト」について解説します。このプロジェクトは、リーディングマークが提供しているミキワメ適性検査とミキワメウェルビーイングサーベイ(以下、「適性検査」「サーベイ」)のデータ構造を改善し、開発スピードの向上とシステムのスケーラビリティを実現することを目的としていました。


「受検者データ分離プロジェクト」は、2023年末に起案され、2024年1月から3月の約3ヶ月かけて開発が行われました。このプロジェクトの背景には、ミキワメ適性検査における課題がありました。従来、新卒・中途・社員の受検データを単一のテーブルで管理していましたが、事業拡大と新サービスの追加に伴い、このデータ構造が開発スピードの制約要因となっていました。
そこで、80万件を超えるデータを分割し、よりスケーラブルなシステムを構築することを目指しました。具体的には、単一のテーブルで管理していた新卒・中途・社員の3区分のデータを、3つの独立したテーブルに分割することが主な目的でした。
開発チームは、テックリード1名、適性検査開発チームから1名、サーベイ開発チームから1名の計3名で構成されました。私は主に適性検査の範囲を担当しました。
プロジェクト実施時点で、バックエンドにはRuby on Rails(バージョン6.1)を使用していました。 この記事では、このプロジェクトの設計方針、技術選定、移行プロセス、直面した課題とその解決策について詳しく解説していきます。


プロダクトの課題とプロジェクト背景

前提として、弊社プロダクトのうち、適性検査・サーベイは同一のデータベースを参照しています。
ミキワメ適性検査の受検者データは、単一の examinees テーブルで管理され、新卒・中途・社員の3種類のユーザーを区別するために examinee_type カラムを使用していました。
このデータ設計では、企業に属する社員の情報を取得する際に、適性検査の受検データを経由する必要がありました。(図1)

[図1] 図1

この構造では、サーベイ機能を独立させる際に不要な依存関係が生じるため、受検者のテーブルをstudentsworkersemployees に分離することで直接企業と紐付けられるようにしました。受検者と紐づいている受検データにはリレーションを張っていたのですが、ポリモーフィック関連を用いて擬似的にリレーションを張ることにより、データの相関を実現する形となっています。(図2)

[図2] 図2

先の図1で示したプロジェクト開始前のデータ構造では、ユーザーは適性検査の受検を通じて初めて企業に属することがわかるかと思います。
この構造の問題点は、社員がサーベイに回答するには、事前に適性検査を受検して企業に属している必要があったことです。当初は運用上大きな問題にはなりませんでしたが、企業が社員に適性検査とサーベイを同時に依頼する機能を実装する際、データ上企業に属していない社員へのサーベイ発行が課題となりました。
一時的な対応も可能でしたが、将来的に社員向けサービスを展開する上で、この問題が繰り返し障害になることが予想されました。結果として、このデータ構造がプロダクト全体の拡張性を制限し、開発の難易度を高めていました。
既存の構造では、企業に属する社員の情報を取得するために、適性検査の受検依頼を介する必要があり、これは無駄に複雑な構造でした。


プロジェクトを進行するにあたっての課題

プロダクトの課題を改善できる理想状態を定義できたものの、プロジェクト進行上の課題も多く存在しました。

プロジェクトの主な課題:

  1. データ構造の大規模変更
    受検データはプロダクトのコアであり、影響範囲が広い
  2. ビッグバンリリースの回避
    他プロジェクトと並行して進めるため、段階的な移行が必要
  3. データ同期の複雑性
    旧テーブルと新テーブルの間で一貫性を保つ必要がある
  4. トランザクション管理
    エラー時のロールバックや同期処理の信頼性が求められる(図3)

[図3] 図3

これらの複雑かつ大規模な課題に対して、システムの安定性を維持しながら慎重に対応することが求められました。
それ考慮した上でどのような戦略を立て、どのような手順を踏んでプロジェクトを進めていったのかを説明していきます。


プロジェクト進行の戦略と手順

図4の手順に従ってプロジェクトを進行しました。

[図4] 図4

これら手順を具体的に解説していきます。

前準備

  • 受検者区分ごとに必要なカラムの精査
  • 受検者情報を置き換える必要があるアプリケーションのリソースの洗い出し
  • 新受検者テーブル、およびそれらの関連テーブル(サインイン情報を持つテーブルなど)の作成

新テーブル作成と既存周辺テーブルの関連付け

  • 各区分の受検者テーブルへ旧受検者テーブルの情報を関連付けするためのカラム設定
  • 旧受検者テーブルに対する外部キーを保持しているテーブル(受検データなど)へ、新受検者テーブルの情報をポリモーフィックで保持するためのカラムの追加、または社員のみのリレーションで十分なテーブルも存在していたため、その場合は社員テーブルに対する外部キーのカラム追加

旧受検者データを新受検者データへ複製

約80万人(2024年1月1日時点)存在していた受検者のデータをstudents, workers, employeesそれぞれのテーブルへ移管する必要がありました。
問題点として、プロダクトリリース当初のバリデーションが十分に記述されていなかった時代に保存された受検者のデータが存在しており、それらを検知するためにいつ終わるかわからない分量のデータを移行するバッチを走らせて確認していました。
具体的には以下のような不整合のパターンが存在しました。

  • employees テーブルでは現在、セイメイ(仮名姓名)の保存が必須だが、過去にはブランクが許容されていた
  • 新しい受検者のテーブルには、メールアドレスのフォーマットに制約があるが、以前のデータには全角英数が許容されていたりと、制約が不十分であった
    このような過去データを新しい制約のあるテーブルへ複製する際、バッチ処理を実行して初めて問題に気付き、その対応に苦労しました。
    最終的には、目的が「複製すること」だったので、セイメイ(姓名)のnot NULL制約などDBレベルで制約がかかっているものに関しては仮の値を、メールアドレスのフォーマットなどアプリケーションレベルで制約がかかっているものに関しては一時的にアプリケーションの制約を外して対応することになりました。

新旧テーブル間データ同期の設定

  • 旧受検者テーブルのデータに変更が加わった時に、新受検者テーブルの対応するデータも変更するための処理
  • またその逆の、新受検者テーブルのデータに変更が加わった時に、旧受検者テーブルの対応するデータも変更するための処理
    これらは更新・作成・削除のそれぞれで行う必要があり、モデルにコールバックを記述することで対応しました。それぞれafter_update, after_create , after_destroy を用い、同期対象となるテーブルのデータを操作する形としました。
    以下のコードは、ExamineeモデルとStudentモデルにおけるコールバックの処理の記述を表したものです。(Employeeモデル, Workerモデル, update, destroyに関しては省略します。)
class Examinee < ApplicationRecord
after_update :update_student

private

def update_student
self.student.update!(**{some_attributes})
end
end

class Student < ApplicationRecord
after_update :update_examinee

private

def update_examinee
self.examinee.update!(**{some_attributes})
end
end

コールバック内でデータを操作する処理の中で仮にエラーが発生した場合、トランザクションはどうなるのかという問題がありましたが、最初に操作を受けたテーブルのデータの処理からコールバック内で発火したデータ操作の処理は、同一トランザクションで囲まれる形となっているため、特段気にすることなくコールバックの記述をすることができました。
しかしながらここには一点問題があり、例えばExamineeの更新がコールバックでStudentの更新を呼んだ時、Studentの更新もコールバックでExamineeの更新を呼んで、さらにまた、、、という無限ループが発生してしまいます。
この無限ループ問題に対して、他社事例も参考にしながらの対処を行いました。
まず、両方のモデルでattr_accessorを使用して、一時的なインスタンス変数skip_double_writeを定義します。このようにすることで、インスタンスのライフサイクルの中でしか存在しないskip_double_write がそれぞれのActiveRecordインスタンスのカラムのように振る舞います。
このキーを、Examineeクラスのコールバックで呼ばれるプライベートメソッド内でtrueとしてStudentインスタンスに受け渡し、Studentクラスのコールバックの発火条件として利用することで、「ExamineeクラスのコールバックでStudentインスタンスが更新された場合、StudentクラスでExamineeインスタンスの更新を行うコールバックを発火しない」という、更新が再帰的に発生しないような挙動の実現が可能となります。
一部簡略化していますが、具体的なコードとしては以下です。

class Examinee < ApplicationRecord
attr_accessor :skip_double_write

after_update :update_student, unless: :skip_double_write

private

def update_student
self.student.update!(**{some_attributes}, skip_double_write: true)
end
end

class Student < ApplicationRecord
attr_accessor :skip_double_write

after_update :update_examinee, unless: :skip_double_write

private

def update_examinee
self.examinee.update!(**{some_attributes}, skip_double_write: true)
end
end

これらの設定を行うことにより、無事同期的にデータの整合性を保つ仕組みを整えることができました。もちろんここにはパフォーマンス観点で問題がありましたが、ここに関しては一定許容していただきつつ運用することとなりました。

アプリケーションが参照しているテーブルを旧テーブルから新テーブルへ全て移行

ここでは前述したように、段階的に既存処理の移行を図りました。
当時、30以上の機能が旧受検者を参照している形であったため、その全てを新受検者のテーブルへ移管する必要がありました。
ここに関しては特に何かを工夫したわけではありませんでしたが、一つ一つドメインを把握している必要があり、入社2年弱であった私もキャッチアップから入る箇所もあったため、正直一番大変な作業でした。
ただ移行作業をするだけでなく、一部大きく処理を変えなければならない箇所も出てきました。
例えば受検データの検索においては、受検者のメールアドレスなどで検索することができるUIが存在していて、その場合関連テーブルとしてexaminees単体の検索で良かったものが、students, workers, employeesそれぞれを跨いだ検索ができるような機能に修正する必要がありました。
そのほかにも煩雑なコードやロジックが残されている箇所に関しては、そのまま移行するのではなく、全体的なリファクタリングを行った上で移行作業をしていったり、そもそも機能をほぼ作り直したような箇所もあったため、思ったようにタスクが消化できない時もありましたが、最終的には無事に移行作業を完了することができました。

対応漏れログの抽出用の設定

移行作業は完了したものの、間髪入れずにexamineeクラスを削除するのはリスクが高かったため、実際にexamineeが呼ばれている箇所がないかをログとして抽出できるように、examineeクラスに以下のような記述をしました。

  after_initialize do |_examinee|
backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/)
Rails.logger.info "Examinee initialize. #{backtrace}"
end

after_find do |_examinee|
backtrace = caller.grep(/#{Regexp.escape(Rails.root.to_s)}/)
Rails.logger.info "Examinee find. #{backtrace}"
end

after_initializeでexamineeインスタンスが初期化された後、また、after_find でexamineeモデルを介してデータベースに検索をかけにいった際に、ログを残すような仕組みを残しました。
こうすることで、一定期間ユーザーがサービスを実際に利用する中で本当にexamineesテーブルに対する取得や操作は行われていないのかを確認することができます。
このおかげで、レガシーとなったexamineeモデルを安心して削除できるような状態を実現できています。

旧受検者モデルの削除

対応漏れの確認と検証を十分に行った上で、アプリケーションから旧受検者であるexamineeの概念を取り除きます。
アクセスがされないモデルが残ってしまうため、モデルとそれらに関連するファイルを削除しました。
移行先の新テーブルにデータは残っていますが、念の為旧テーブルのデータ自体の削除は一定期間行わない方針です。


プロジェクトを振り返って

このプロジェクトを通じて、データ構造のシンプル化と、プロダクトの拡張性向上を実現しました。
特に、段階的な移行戦略やデータ同期の仕組み、トランザクション管理の工夫は、同様の課題を抱えるエンジニアにとって参考になるポイントかと思います。
今後も、システムの進化に合わせて最適なデータ設計を模索しながら、継続的に改善を進めていきます。

· 約8分
山本

こんにちは。サービス開発部プロダクトエンジニアの山本です。

今回は16タイプマッピングという機能開発において、バックエンドを介さずフロントエンドのみでPDF生成を実装した経験についてお話しします。

PDFを生成する場合、一般的にバックエンドを介して処理を行いますが、今回の開発ではバックエンドを使わずフロントエンドのみで実装する必要がありました。

既存のReactコンポーネントをそのままPDF化したかったためです。本記事では、html2canvasとjsPDFを活用し、フロントエンドのみでPDF生成を実装した事例について紹介します。

背景

16type16type分布図

ミキワメ適性検査では16タイプマッピングという機能を提供しています。

上記のようにミキワメの性格検査の結果から16にタイプ分類をして、会社全体や部署ごとに性格特性の分布図を作成することが可能です。

チームビルディングなどに活用され好評を博している機能なのですが、ここにPDFダウンロード機能を追加しました。

課題


ミキワメ適性検査のフロントエンドはRailsのテンプレートエンジンであるhamlからReactへ移行中のため、その2つが混在している状況です。(2025/01 時点)

hamlからのPDF生成はすでに実装していましたが、Reactで作成された画面を直接PDF化した実績はありませんでした。

Reactで書かれたコンポーネントをPDF生成用にhamlで再作成し、従来通りバックエンドを介して実装することも検討しましたが工数がかかること、せっかくReactに移行したのにまたhaml書くんかいという気持ちがあり、もっと手軽にPDF生成する手段を模索することになりました。

技術選定


以下の条件で選定を進めました。

  • 導入コストが低い
  • 実装し易い
  • 主流ブラウザで動作可能
  • React Emotionが期待通り動作する

バックエンドを介さずPDF生成をしたいという需要は一定あると思うのですが、意外と選択肢が少なく、結論としてはhtml2canvasとjsPDFを組み合わせて採用することになりました。

html2canvasはHTML要素をキャプチャし画像化することが出来、jsPDFは画像からPDF生成が可能で、いずれもJavaScriptライブラリです。

react-pdfはJSXで直接レイアウトを記述できる点が魅力的でしたが、React v17までの対応に留まっており、ミキワメ適性検査が採用しているReact v18では動作しませんでした。また、Emotionの互換性もないため、今回の要件には適合しませんでした。

選定条件html2canvas + jsPDFreact-pdf
導入コストが低い✅ 導入が容易✅ 比較的容易
実装し易い✅ シンプルなAPI✅ JSXで直感的に記述可能
主流ブラウザで動作可能✅ Chrome, Firefox, Edge, Safari✅ Chrome, Firefox, Edge, Safari
React Emotionが動作✅ 影響なし❌ 互換性なし React v18に未対応
バックエンド不要でPDF生成✅ 可能✅ 可能
レイアウトの自由度⚠️ HTMLレンダリングに依存✅ JSXで柔軟に記述可能
PDFのカスタマイズ性✅ 縦横・サイズ指定可能✅ 詳細なスタイル調整可能

直面した課題


  • 一部のスタイルが反映されない
  • ブラウザ上でレンダリングが必要なため、表示の工夫が必要
  • 既存機能と一貫性のあるダウンロード操作を実装する必要がある

解決策


  • スタイルの問題は、デザイナーと相談し、許容範囲を判断
  • 一旦非表示エリアにPDF生成用コンポーネントを表示し、見えない形でPDFを生成
  • 既存のダウンロードフローと統一し、ユーザーのUXを維持

デモで使用した実装


シンプルですが以下でブラウザに表示させたPDF生成用コンポーネントをユーザが視認できないよう調整しました。

  1. ダウンロードボタン押下をトリガーにPDF生成用コンポーネントをブラウザに表示
  2. PDF生成、ダウンロード処理
  3. 完了後非表示にする

最終的な実装ではベースレイアウト外でPDF生成用コンポーネントを表示させ、視認のリスクを軽減させています。

export const PdfDownloadButton = ({ className }: Props) => {
const [showPDF, setShowPDF] = useState(false); // PDF生成用コンポーネントを表示する状態管理

// ボタンクリック時にPDF生成用コンポーネントを表示
const handleClick = () => {
setShowPDF(true);
};

// showPDF が true になったら PDF を生成し、完了後に元の状態に戻す
useEffect(() => {
if (showPDF) {
PdfDownload(); // PDF生成処理を実行
setShowPDF(false); // PDF生成が終わったら状態を元に戻す
}
}, [showPDF]);

return (
<div>
{/* PDFダウンロードボタン */}
<Button onClick={handleClick} className={className}>
PDFダウンロード
</Button>
{/* PDF生成用コンポーネント(表示されるとPDFを作成) */}
{showPDF && <PdfTargetComponent />}
</div>
);
};

// PDFを生成する関数
const PdfDownload = () => {
const target = document.getElementById('pdf-target'); // PDF対象の要素を取得
if (target === null) return;

// 取得した要素をPDFとして生成
generatePdf(target);
};

まとめ


フロントエンドのみでPDF生成を行う方法として、html2canvas + jsPDFは有力な選択肢のひとつです。特に、既存のバックエンドを改修せずにPDFを導入したい場合や、Reactで作成されたコンポーネントをそのままPDF化したい場合に有効です。一方で、スタイルの反映やブラウザレンダリングの工夫が必要になるため、導入前に許容範囲を整理することが重要です。

· 約7分
片所 宏介

こんにちは。サービス開発部技術基盤部門のSREエンジニアの片所です。

今回は、私が実施したAWSのコストカット施策について紹介したいと思います。 リーディングマークでは、サービスの成長とともにAWSの利用料が増大していました。特に、Amazon Inspectorの利用料が想定以上に高く、ECRのスキャン回数が大きな要因となっていました。 本記事では、ECRのスキャン回数を最適化し、Inspectorのコストを75%削減した具体的な方法を共有します。

AWSにおけるコスト面の課題について

弊社のプロダクトは、日々多くのユーザーの皆様にご利用いただき、着実に成長を続けています。この成長に応えるべく、開発チームは常にスピード感を持って新機能の開発や既存機能の改善に取り組んでいます。

しかし、サービスの拡大と開発の加速に伴い、私たちはある課題に直面しました。それは、AWSの利用料の慢性的な増加です。特に以下の要因が大きく影響していました。

  1. トラフィックの増加に伴うリソース使用量の増大
  2. 新機能開発や検証のための環境の増設・リソースの増加

1の要因については利用企業の増加(2024年5月時点で約3000社、2025年1月時点で約4000社に増加)に伴うものであるということやインフラ構成の大幅な見直しが必要な可能性があるため、より迅速にコストを最適化するために2の要因に対して優先的に取り組むことにしました。

そのためにまず、AWS利用料の詳細な分析を行いました。その結果、RDSやECSといった主要サービスの次に、Amazon Inspector(以下、Inspector)の利用料が大きな割合を占めていました。Inspectorは弊社のプロダクトに対するセキュリティ面での重要な役割を果たしていますが、その利用料が予想以上に高額であることが判明しました。 そこで、セキュリティレベルを維持しながら、いかにInspectorのコストを最適化できるかという課題に取り組むことにしました。

Amazon Inspectorとは何か

Inspectorは、Amazon EC2、AWS Lambda関数、Amazon ECRの脆弱性を検出するためのセキュリティサービスです。 弊社では、主にECRの脆弱性スキャンに利用していました。しかし、スキャン頻度と対象リソース数によって利用料が大きく変動するため、最適化の余地がありました。

コストが増大してしまった原因

ECRにタグなしの古いイメージ(中には1年以上前のものも)が多数残っており、これらが自動で定期的にスキャンされていたため、Inspectorのコストが大幅に増加していました。 主な原因としては、開発初期にイメージのローテーションを実装していなかったことが挙げられます。

今回実施したコストカット方法について

そこで今回私はコスト削減のために、開発チームと協議のうえ、不要なイメージを削除するライフサイクルポリシーを導入することにしました。 具体的には、ECRのリポジトリ上の「pushから7日以上経過し、タグが付いていないイメージ」を自動削除する設定を行いました。

手順について

ECRのライフサイクルポリシーを利用し、タグなしのイメージを7日後に自動削除する設定を行いました。 具体的な設定手順については、AWS公式ドキュメントも参考にしてください。https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/LifecyclePolicies.html

*実際に行った設定

note

実装した結果

この方法を実施した結果、Inspectorの月間利用料を75%削減することに成功しました。また、ECRの利用料も84%削減され、大幅なコスト最適化を実現しました!

note

まとめ

今回はECRのライフサイクルポリシーを活用し、不要なイメージの削除を自動化することで、InspectorとECRのコストを大幅に削減しました。

Inspectorは強力なセキュリティツールですが、運用方法によってはコストが増大してしまうリスクがあります。セキュリティレベルを維持しつつコストを抑えた運用を行いたい場合は、スキャン回数の最適化をどのように実現するかが重要になります。 もし同様の課題を抱えている方がいれば、一度スキャン頻度やECRの管理方法を見直してみることをおすすめします!

· 約8分
山田 哲也

こんにちは。山田です。

今回は、AWS Copilot CLI を使って SPA をデプロイする方法を紹介したいと思います。

AWS Copilot CLI は、コンテナベースのアプリケーションや SPA を簡単にデプロイできるツールで、
ほぼコーディングなしでアプリケーションに必要なインフラを構築してデプロイできるので個人的にとても好きなツールです。

今回は SPA をデプロイする方法について紹介したいと思います。

事前準備

まずは AWS Copilot CLI をインストールします。

$ brew install aws/tap/copilot-cli

インストールが完了したら、copilot コマンドが使えるようになります。

$ copilot --version
copilot version: v1.34.0

次に SPA のプロジェクトを作成していきます。

SPA は Remix で作ろうと思うので、公式の SPA テンプレートを使ってプロジェクトを作成します。

$ mkdir spa
$ cd spa
$ npx create-remix@latest --template remix-run/remix/templates/spa

プロジェクト作成後のディレクトリはこんな感じになっていると思います。

$ tree -a -L1
.
├── .eslintrc.cjs
├── .gitignore
├── README.md
├── app
├── node_modules
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── tailwind.config.ts
├── tsconfig.json
└── vite.config.ts

4 directories, 9 files

AWS Copilot CLI のコンセプト

後はデプロイするだけなんですが、デプロイ前にいくつか手順があるので概念の説明もしておこうと思います。

まず、Copilot には「Application」、「Environment」、「Service」の 3 つの概念があります。(Job と Pipeline も含めると 5 つ)

名前の通りではあるのですが、Application はそのアプリケーション全体を指し、Environment はアプリケーションをデプロイする環境(本番、検証など)、
Service はアプリケーションの中で動くサービス(API、フロントエンドなど)のことを指しています。

イメージ図

copilot

なので AWS Copilot CLI で環境を構築する際は、まず Application を作成して、その中に Environment を作成し、さらにその中に Service を作成していくというイメージを持つといいと思います。

Application と Environment の作成

$ copilot app init

作成するアプリケーションの名前を聞かれるので入力します。
実行が完了するとディレクトリ内に copilot というディレクトリが作成され、.workspace というファイルが作成されます。
このファイルにはアプリケーション名が記載されています。
また、裏側では CloudFormation が動いていて、アプリケーション内で共通で利用される IAM ロールが作成されます。

次に Environment を作成します。

$ copilot env init

ここでは環境名、利用する AWS アカウントのクレデンシャル名、VPC などの設定を聞かれるので入力します。
実行が完了すると copilot ディレクトリ内に environment の構成ファイル manifest.yml が保存されます。
また、この時裏側では CloudFormation によって、S3 バケットや KMS キーなどが作成されます。

この時作成される設定ファイルは以下のようになっています。
コマンド実行した時に聞かれる質問で network 設定をデフォルトにしているためコメントアウトされていますが、項目に設定を追加することで既存の VPC 上に環境を作成することも可能です。

# The manifest for the "staging" environment.
# Read the full specification for the "Environment" type at:
# https://aws.github.io/copilot-cli/docs/manifest/environment/

# Your environment name will be used in naming your resources like VPC, cluster, etc.
name: staging
type: Environment

# Import your own VPC and subnets or configure how they should be created.
# network:
# vpc:
# id:

# Configure the load balancers in your environment, once created.
# http:
# public:
# private:

# Configure observability for your environment resources.
observability:
container_insights: false

ちょっと混乱するかもですが、app 以外のcopilot xxxx initは基本的に構成ファイルを作成するためのコマンドなので init とは別に deploy コマンドを使って構成を反映させる必要があります。

$ copilot env deploy

実行すると、manifest ファイルに記載された構成が反映されて VPC などのリソースが作成されます。
CloudFormation のスタックを見ると deploy 実行後にリソースが追加されていることが確認できると思います。

Service(SPA)のデプロイ

それでは準備が整ったので SPA をデプロイしていきます。
まず Remix のビルドを行います。

$ npm run build

ビルドが完了すると、build/client ディレクトリにビルドされたファイルが出力されます。

次に Service を作成します。

$ copilot svc init

Load Balanced Web Service などデプロイ構成の選択肢が出てくるので、Static Site を選択します。
次にサービス名を聞かれるので適当な名前を入力します。今回は remix とします。
次にビルドした静的ファイルのディレクトリを聞かれるので build/client を入力します。

実行が完了すると copilot ディレクトリ内に service の構成ファイル manifest.yml が保存されます。

# The manifest for the "remix" service.
# Read the full specification for the "Static Site" type at:
# https://aws.github.io/copilot-cli/docs/manifest/static-site/

# Your service name will be used in naming your resources like S3 buckets, etc.
name: remix
type: Static Site
files:
- source: build/client
recursive: true
# You can override any of the values defined above by environment.
# environments:
# test:
# files:
# - source: './blob'
# recursive: true
# destination: 'assets'
# exclude: '*'
# reinclude:
# - '*.txt'
# - '*.png'

それではデプロイコマンドを実行します。

$ copilot svc deploy

実行すると、environment deploy で作成された staging 環境に remix サービスがデプロイされます。
裏側では CloudFormation によって S3 や CloudFront といったリソースが作成され、ビルドした静的ファイルが S3 にアップロードされます。

デプロイが完了すると CloudFront の URL がコンソールに表示されるので、ブラウザでアクセスすると Remix にアクセスできます。

remix

以上、AWS Copilot CLI を使って SPA をデプロイする方法について紹介しました。
ちょっと長くなってしまいましたがイメージさえ掴めれば使い心地は非常に良いので、とてもおすすめです。

ご覧いただきありがとうございました!

余談

実は AWS Copilot CLI の最終リリースが 6 月 27 日となっていて、しばらく更新がありません 😭
メンテナンスモードに移行したという噂もあり今後の動きが気になるところです。

参考

· 約8分
森田 有貴

森田です。

早いものでこの記事で私が執筆したものが 10 記事目となります。 普段は技術的なお話を書いていますが、今回はテックブログを書いている中で感じたことなど書いていこうと思います。

なぜテックブログを書いていたのか

自分自身の学びを深めるためというのが大きかったですね。新しい技術に触れるたびにメモを残しておきたい気持ちはあるものの、ただ自分用のメモにしておくのももったいないと感じていました。どうせなら自分が得た知識をまとめて共有しよう、という気持ちがありましたし、そうすることで他のメンバーにも役立つかもしれない。何より、知識を文章にすることで自分の理解もよりしっかりとしたものになると感じていたので、テックブログを書くことには自然とやりがいを見出していました。

テックブログを書く中で感じたこと。

正直な感想を言ってしまうと、めちゃめちゃ大変でした。大変でもあり、楽しくもありました。

そもそもの話になるのですが、私文章を書くのがものすごく苦手です。なのでどのように文章にしたら読み手にわかりやすく伝わるのかというところにかなり時間をかけた記憶があります。 言い回しの修正などには結構 GPT を活用したりしました。

また、技術的な内容ですと普通の一般的なブログとは異なり、ブログを書き始める前に実際にコードを書いて動くものを作ったりする必要があります。 なので記事の執筆以前に準備にものすごい時間がかかりました。どれだけのプロジェクトを作成したことか...。特に action cable で同時編集を実装する記事では react と rails 両方の環境構築をして色々設定したりしていたので一番時間がかかりました。

とは言ってもそれ以上に楽しく記事を書いていたと思います。これは私の性格的なところもあると思うのですが、やはりエンジニアとして新しい技術に触れたり未知の問題に挑戦することが好きなんですよね。 プロダクトの開発の中で新しく使った技術や解決した問題を一般化して記事として残すというのは、自分自身の考えをまとめることができ、さらに他の人にも役立つ知見を共有できる良い機会でした。

また、社内外かなり多くの方に読んでいただいたようで、さまざまなフィードバックをいただけていました。「参考になった」と言われた時はやはり一番やりがいをを感じましたし、普通に嬉しかったですね。

新卒エンジニアに向けて

新卒エンジニアでも記事書きましょう!ということを言いたいです。

理由としましては、テックブログを書くということは自分の知見を世界中の人に共有できるということももちろんあるのですが、自分の技術力を見せつけることができるという側面もあります。 自分が取り組んできたプロジェクトやそこで学んだ技術や知見を発信することで、他のエンジニアや採用担当から「この人はこんな技術を使えるんだ」と認知してもらえる場になります。 さらに書いた記事を自分のポートフォリオの一部として活用することもできます。 ペース的には半年に 1 本くらいでいいと思うので自分の実績を記事として記録に残しておくと良いと思います。

今後

これからも引き続き技術について学んだことや感じたことを発信していきたいと思います。記事を書くことで自分の理解が深まり、他の人の役に立てることがわかりましたし、それがやりがいにも繋がっています。新しい技術や取り組みに触れながら、今後も記事を通じて成長の過程を記録し、共有できればと思っています。

最後に

これまで私の記事を読んでくださった皆様、ありがとうございました。ちゃんとテックブログを書くのは初めての経験だったのでなかなか苦戦することもありましたが、得られた物も多かったと思います。 開発の技術的な面でも記事の執筆的な面でも、自分の成長をひしひしと感じました。今後も引き続き技術を深掘りし共有できればとお思っていますので、引き続きご愛読賜ればと思います。

では。

· 約5分
森田 有貴

森田です。

普段は何かしらの機能の実装の話をしてますが、今回は毛色の異なるお話をしようかと思います。

では早速こちらのコードをご覧ください。

import { useEffect, useState } from "react";

type Address = {
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
};

export const App = () => {
const [address, setAddress] = useState<Address>();
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060"); //fetchでデータを取得
const data = await response.json();

setAddress(data.results[0]); //data.results[0]をaddressにセット
setLoading(false); //fetchが終わったらloadingをfalseにする
};

fetchData();
}, []);

if (loading) {
return <div>loading...</div>;
} else {
return <div></div>;
}
};

やっていることとしてページをロードした際に指定した郵便番号からどこの住所のものなのかを取得しているだけです。

fetch中はloading...と表示し、終わったら取得した内容を表示するといったページです。

では問題です。addressの型は何でしょうか。

 

 

 

 

  〜シンキングタイム〜  

 

 

 

   

正解は...

Address | undifnedです!正解できましたかね?

そうなんです、useStateで初期値を設定していないのでundefinedがくっついてしまうんです。

なのでaddressの値を表示させようとすると{address?.address1}とオプショナルプロパティをつけて書かないといけなくなってしまうのです。これはよろしくないですね。

これをtypescriptの良さを活かして解決してみましょう!

解決方法としてはaddressを定義する際にその型を(Address & { loading: false }) | { loading: true }とし、その初期値を{ loading: true }とするというものです。

つまり

const [address, setAddress] = useState<(Address & { loading: false }) | { loading: true }>({ loading: true });

とするということです。

で、fetchしたデータをaddressに代入する際にloadingの値をfalseにします。

こうすることによってaddress値はundefinedにならなくなり、さらに別でloadingの値を設定する必要がなくなるのです。

文面だけだと何言ってるか少しよくわからないですね。

最初のコードを実際に編集してみましょう。

import { useEffect, useState } from "react";

type Address = {
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
};

export const App = () => {
const [address, setAddress] = useState<(Address & { loading: false }) | { loading: true }>({ loading: true });

useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060"); //fetchでデータを取得
const data = await response.json();
setAddress({ ...data.results[0], loading: false }); //data.results[0]とloading:falseをセット
};

fetchData();
}, []);

if (address.loading) {
return <div>loading...</div>;
} else {
return <div>{address.address1}</div>;
}
};

このようにaddress.loading === falseになっている時だけaddressの値を表示するようにすると通常通り{address.address1}と表示できるようになるというわけです!

めちゃめちゃtypescriptの良さを活かしたtypescriptだからこそできる手法ではないでしょうか。

ちなみにこちらはチームのメンバーさんが見つけてくださったうひょさんの記事を参考にしています。 https://qiita.com/uhyo/items/d74af1d8c109af43849e

実際に社内のプロダクトでもこの手法を取り入れて開発をしています。

もしよかったら参考にしてみてください!

では。

· 約7分
森田 有貴

森田です。

前回の記事で blocknote と yjs と liveblocks を用いた共同編集の実装を紹介しました。

とても簡単に実装できるというは大きな利点なのですが、これは Nextjs ありきの実装となっており、react ではどう実装するのという話になってしまいます。

なので今回は React + Rails の構成で yjs と ActionCable を用いたコンフリクトフリーな共同編集を実装してみましょう!

info

Rails は開発環境構築が完了しており、5000 ポートで立ち上がる前提で進めます。 フロントは Vite + React で開発します。

実装

Rails

まずActionCableを使うにあたって必要なgemをインポートしておきます。

/config/application.rb
require "action_cable/engine"

続いて開発環境のみでどのオリジンからもActionCableに接続できるようにしておきます。 該当する行のコメントアウトを外すだけです。

/config/environments/development.rb
config.action_cable.disable_request_forgery_protection = true

では実際にActionCableを作成してみましょう。

ストリーム先を自由に選択できるようにしたいので、idによって変化するようにします。

/app/channels/sync_channel.rb
class SyncChannel < ApplicationCable::Channel
def subscribed
document_id = params[:id]
stream_from("document-#{document_id}")
end

def receive(message)
document_id = params[:id]
ActionCable.server.broadcast("document-#{document_id}", message)
end
end

React

続いてフロント側の実装をしていきます。

まず必要なライブラリをインポートしていきます。

npm i @rails/actioncable @types/rails_actioncable @y-rb/actioncable   

おやおや?

何やら怪しいライブラリが含まれていますね...

y-rb/acioncableとはなんぞや?と思われたそこのあなた。鋭いですねぇ

今回も前回と同様にyjsというフレームワークを用いてコンフリクトフリーな共同編集を実装します。 ところがyjsはjsと付いているようにjavascriptを前提に作られているフレームワークです。 なのでrailsで扱うには少し不都合なのです。

そこで登場するのがこのy-rb/actioncableです。 公式の説明を簡単に翻訳すると、「yjsクライアントとRailsのActionCableチャネルを使用してWebSocket接続を設定するために必要なJavaScriptとRubyの依存関係を提供してくれる」とのことです。

詳しくはこちら↓

https://github.com/y-crdt/yrb-actioncable

こんな便利なものがあるなら使わない手はないということでしっかり組み込んでフロントを実装していきましょう。

BlockNote関連の部分は前回の記事を参照ください。

/src/App.tsx
import { WebsocketProvider } from "@y-rb/actioncable";
import { createConsumer } from "@rails/actioncable";
import * as Y from "yjs";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

export const App = () => {
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();

useEffect(() => {
const consumer = createConsumer("ws://localhost:5000/cable");
const yDoc = new Y.Doc();
const yProvider = new WebsocketProvider(yDoc, consumer, "SyncChannel", { id: "1" });
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, []);

if (!doc || !provider) {
return null;
}

return <BlockNote doc={doc} provider={provider} />;
};

type EditorProps = {
doc: Y.Doc;
provider: any;
};

function BlockNote({ doc, provider }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: "User",
color: "#ff0000",
},
},
});

return <BlockNoteView editor={editor} />;
}

実装はほとんど前回と一緒ですね。

/src/App.tsx:16
const consumer = createConsumer("ws://localhost:5000/cable");

@rails/actioncable"を用いてcosumerを設定しています。今回Railsを5000ポートで立ち上げているのため上記のようになっているので、ここはそれぞれの環境に合わせて書き換えてください。

/src/App.tsx:18
const yProvider = new WebsocketProvider(yDoc, consumer, "SyncChannel", { id: "1" });

そして@y-rb/actioncableを用いてproviderを設定しているという感じですね。ここのidを変更することによってストリーム先を変更することができるので、必要に応じて変数にしたりしてみてください。

というわけで実装はここまで。

フロントとバックエンドそれぞれを立ち上げてみると...

video1

完成!🎉

課題

とりあえず共同編集はできるようにはなったのですが、この実装ではリロードすると記入内容が消えてしまいます。本来であれば、ActionCable側が接続されたことを感知してdbに保存されている内容を参照して送り返すという処理を挟むべきなのですが、yjsを使っているため従来のやり方ではできないのです。 今色々と試している段階なので、何か良い解決策が見つかったらまた記事にしてお届けしたいと思います。

終わり

というわけでyjsとActionCableを用いた共同編集の実装を紹介しました。ちゃんと動く状態になるまで試行錯誤してかなり時間がかかった記憶があるのですが、コードで見てみると結構簡単に実装できますね。 かなり拡張性のあるものだと思うので、ぜひ色々いじってみてくださ。何か面白いことができたりしたら逆に教えて欲しいです。

ではまた。

· 約10分
森田 有貴

森田です。 先日 BlockNote について紹介しましたが、今回は BlockNote と yjs と Liveblocks を用いた共同編集機能の実装をご紹介します。

共同編集と聞くと難しそうに感じますが、そんなことは全くなくとても簡単に実装できます。

早速実装といきたいところですが、少しだけ yjs と Liveblocks について説明を...

yjs とは

yjs は CRDT(Conflict-free Replicated Data Type)という技術を持ちたフレームワークです。つまりコンフリクトしないデータの型を扱うことができます。

詳しくはこちら ↓

https://docs.yjs.dev/

Liveblocks とは

Liveblocks はリアルタイムでの共同編集やカーソル共有などのコラボレーション機能を簡単に追加できるツールキットです。

公式ドキュメントがとても充実しているので詳しくはこちらを参照ください ↓

https://liveblocks.io/docs

準備

Nextjs を作成。今回は App Router + Typescript でいきます。そのほかは好みで設定してもらえればいいと思います。

$ npx create-next-app collaborative-editor
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

Liveblocks 周りの設定

Liveblocks 関連のライブラリをインストールします

npm i @liveblocks/client @liveblocks/react @liveblocks/node @liveblocks/yjs

liveblocks を初期化

npx create-liveblocks-app@latest --init --framework react

何やら質問されるので、y で実行しましょう。

$ npx create-liveblocks-app@latest --init --framework react
Need to install the following packages:
create-liveblocks-app@2.20240816.0
Ok to proceed? (y) y

▀█████▀ ▄
▀██▀ ▄██▄
▀ ▄█████▄

Liveblocks


✨ liveblocks.config.ts has been generated!

これでアプリ側の設定は完了。

続いて API キーを取得しにいきましょう。

まずこちらからサインアップhttps://liveblocks.io/

API keys を選択

image1

Generate key...をクリックして

image2

キーを生成!

image3

Public key と Secret key は後々使うのでメモしておきましょう。

BlockNote の設定

先日の記事と同じように blocknote のライブラリもインポートしておきましょう

npm i @blocknote/core @blocknote/react @blocknote/mantine

実装

ではいよいよ実装に入っていきましょう。

まず先程取得した API キーを環境変数に設定しておきましょう。

/.env.local
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= LIVEBLOCKS_SECRET_KEY=

続いてバックエンドを作成します。

/src/app/api/liveblocks-auth/route.ts
import { Liveblocks } from "@liveblocks/node";
import { NextResponse } from "next/server";
import { nanoid } from "nanoid";

const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST() {
const liveSession = liveblocks.prepareSession(nanoid());

liveSession.allow("*", liveSession.FULL_ACCESS);

const { body, status } = await liveSession.authorize();
return new NextResponse(body, { status });
}

続いてLiveblocks のライブラリを用いてルームの作成接続などの機能のコンポーネントを作成します。

/src/app/Components/Room.tsx
"use client";

import { LiveblocksProvider, RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";
import { ReactNode } from "react";

type Props = {
children: ReactNode;
};

export const Room = ({ children }: Props) => {
return (
<LiveblocksProvider authEndpoint={"/api/liveblocks-auth"}>
<RoomProvider id="test-room">
<ClientSideSuspense fallback={<div>...loading</div>}>{children}</ClientSideSuspense>
</RoomProvider>
</LiveblocksProvider>
);
};

authEndpointは先程作成したバックエンドのエンドポイントを設定しましょう。

次にBlockNoteにyjsを組み込んだエディタを作成します。

/src/app/Components/Editor.tsx
"use client";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@liveblocks/react/suspense";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

type EditorProps = {
doc: Y.Doc;
provider: any;
};

export function Editor() {
const room = useRoom();
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();

useEffect(() => {
const yDoc = new Y.Doc();
const yProvider = new LiveblocksYjsProvider(room, yDoc);
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, [room]);

if (!doc || !provider) {
return null;
}

return <BlockNote doc={doc} provider={provider} />;
}

function BlockNote({ doc, provider }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: "User",
color: "#ff0000",
},
},
});

return <BlockNoteView editor={editor} />;
}

作成したコンポーネントをページに組み込んで...

/src/app/collaborative-editing/page.tsx
import { Room } from "../Components/Room";
import { Editor } from "../Components/Editor";

const Page = () => {
return (
<Room>
<Editor />
</Room>
);
};

export default Page;

はい完成🎉

video1

ちょっと簡単すぎじゃないですかね。

おまけ

ルームの接続設定について

RoomコンポーネントのRoomProviderのidでルーム選択ができるようになっています。

/src/app/Components/Room.tsx:13

<RoomProvider id="test-room">

今回はtest-roomになっていますね。

また Liveblocks のダッシュボードから作成されたルームを管理することもできます。

image4

test-roomが作成されていますね。

またルームの id に制約をかけることもできます。

/src/app/api/liveblocks-auth/route.ts:12
liveSession.allow("room:*", liveSession.FULL_ACCESS);

このようにroom:*とするとルーム id にroom:がついていないと接続できないようになります。

今ルーム id をtest-roomにしているのでそのままでは接続できないようになっているはずです。

room:test-roomにすると再度接続できるようになるということです。

接続中の表示

ClientSideSuspensefallbackで接続中に表示されるないようを変更することができます。以下のように変更してみましょう。

/src/app/Components/Room.tsx:14
<ClientSideSuspense fallback={<div>...読み込み中</div>}>{children}</ClientSideSuspense>

video2.gif

任意のコンポーネントで指定できるので、ローディングでよくあるクルクルするやつなどを表示するのもいいかもしれないですね。

ユーザー名と色の変更

相手に表示される自分のユーザー名と色を変更できるようにしてみましょう。

以下でを設定しています。

/src/app/Components/Editor.tsx:46
user: {
name: "User",
color: "#ff0000",
},

レンダリング時に設定されてないといけないので、それも踏まえてEditorコンポーネントを編集してみましょう。

/src/app/Components/Editor.tsx
"use client";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@liveblocks/react/suspense";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

type EditorProps = {
doc: Y.Doc;
provider: any;
name: string;
color: string;
};

export function Editor() {
const room = useRoom();
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();
const [name, setName] = useState("");
const [color, setColor] = useState("");
const [showEditor, setShwoEditor] = useState(false);

useEffect(() => {
const yDoc = new Y.Doc();
const yProvider = new LiveblocksYjsProvider(room, yDoc);
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, [room]);

if (!doc || !provider) {
return null;
}

if (showEditor) {
return (
<BlockNote doc={doc} provider={provider} name={name} color={color} />
);
} else {
return (
<div style={{ display: "flex", justifyItems: "center" }}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前"
/>
<input
value={color}
onChange={(e) => setColor(e.target.value)}
type="color"
/>
<button onClick={() => setShwoEditor((prev) => !prev)}>接続</button>
</div>
);
}
}

function BlockNote({ doc, provider, name, color }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: name,
color: color,
},
},
});

return <BlockNoteView editor={editor} />;
}

名前と色を入力したらテキストを変種できるようにしたという感じですね。

video3.gif

というわけで実装できました。

おわり

今回は共同編集の実装を紹介しました。 Liveblocksはテキストの編集以外にも相手のカーソル表示やリアルタイムの通知機能など様々な機能が用意されています。 yjsについてもMap型やArray型などあり、使い方によって様々に活用できると思うので、ぜひいろいろ試していただければと思います。

今回テキストエディタはBlockNoteを使用しましたが、他のエディタでもほとんど同様に実装できるようになっています。この辺りの話はLiveblocksのドキュメントを参照してみてください。

ではまた。