MOBILUS TECH BLOG

モビルスのプロダクト開発を支えるメンバーが 日々の開発現場の情報を発信します。

【AI × NFC】推し活の合間にアナログな受付業務をハックした、1ヶ月の開発記録

はじめまして。Project Management Unitのsyo.です。

 

これは、社内イベントの受付で目にした「極めてアナログな業務」を、AIとNFCを使って1か月で改善した実録です。

 

今回は実際の業務改善を楽しく物語風にお届けします。ぜひ楽しみながら読んでください。

 

扉絵は、開発の一幕を表現したイメージ図です。

 

 

最先端企業の「未開の地」

 

2025年11月下旬の全社会合、通称MRT(Monthly Round Table)が終わった後の懇親会にて、私は人知れず、重要な任務を拝命しました。

いや、自ら名乗り出て、とある提案をしたのでした。

 

弊社では、毎月1回、全社員が集って、会社の置かれている課題や進んでいくべき方向性について、確認し合う会合を開いています。社長や役員からのメッセージ、各プロジェクトの紹介、そしてMobilus Valueを体現した社員を表彰する「Value Connection」など、盛りだくさんの内容です。

 

会合後は、別会場へ移動し、立食形式の懇親会へと移ります。美味しい食事を片手に、普段交流の少ない他部署のメンバーと会話が弾みます。月一回のこの楽しみなイベントが、モビルスの結束力を形成していると言っても過言ではないかもしれません。

 

そんな中で私は、入社以来、殊更に違和感を抱いていた光景がありました。

生成AIの最先端を走り、「CX向上」をスローガンに掲げる会社としてはお恥ずかしい限りですが、実にアナログな運用が残っている部分があったのです。

 

それは、MRTの参加受付です。

 

参加者は、A3用紙に印刷された名簿の中から、自身の名前を見つけてボールペンを使ってチェックマークを付けるのです。100人規模となると、受付には長蛇の列ができ、3列で捌くにしても、なかなかスムーズに進まず、開始直前に人が殺到するため、定刻通りに席につけない社員が出るというのが常態化していました。

 

え?

名簿にボールペンでチェックマーク?

 

一応、誤解のないように触れておきます。弊社の執務エリアはセキュリティを意識したゾーニングをしておりまして、入口にはIDカードを用いた入退室管理を完備しております。部外者は入れないどころか、ついうっかりIDカードをかざすのを忘れてドアを素通りすると、自力では二度とドアを開けられないほど、厳重な管理をしています。

 

そうです。

 

最先端の企業が、厳重なセキュリティを完備して、CXをリードすると言っておきながら、その基盤となる場面でアナログ運用という「未開の地」がそこには現存していたのです。

 

突然の提案

 

そして、冒頭の任務へとつながります。

 

この月のMRTは社屋移転をして間もなくで、会合後の懇親会は、いつもの業者さんとは違うケータリングサービスが振舞われていました。このときの業者さんの要請を受けた人数把握のために、懇親会の受付でも同様のアナログな名簿照合をしていました。

 

私は、その光景を目の当たりにして、勇気を出して、こうしたらどうでしょう?と提案をしたのです。

 

「せっかくIDカードを全社員が首から下げているので、これで受付しませんか?」

「え、それできるなら、やりたいです!」

 

二つ返事で進みます。

 

「カードのIDさえ拾えれば絶対できるはずです!作ります!やってみましょう!」

「ぜひ、お願いします!」

 

MRTを企画運営する弊社アドミニストレーションUnit(会社の総務・法務・人事を担う部門。通称アドミ)の方々、何名かの前で、そう言い放った私は、こうして重要案件に着任したのでした。私は、立食を軽く済ませると、その懇親会を離れて即座に自席に戻り、手元の社用スマホを片手に、IDカードの読み取り実験を始めました。

 

構想としては、スマホをIDカードの読み取り機にして、そうやって読み取った受付データを、PCに吸い出して保存する構成にしようと心に決めたのでした。

 

その日は金曜日で、そのまま連休に突入するという、そんな夜だったのです。実のところ、もともと推し活でアイドルライブを観に行く予定があり、限られた時間の中で、私はAIに向けてプロンプトを投げ始めていました。きっと、こうすればIDカードから必要な情報を取り出せるはず。そんな確信と期待の中、結構な長い時間さわって調整を重ねるも、上手くいかないまま予定の時間が迫ってしまい、いったん、中断を余儀なくされたのです。

 

推し活現場での妄想チャレンジ

 

さらにその日は、連休を使って実家に帰省する予定だったため、大荷物を抱えながら、一目散に推し活現場へと向かいました。推しの登場を待っている間もソワソワしながらのライブ鑑賞でした。そして、お目当てのアイドルグループがライブを終えてステージを降りたのち、いわゆる特典会を始めるまでの時間を活用しようと、私はライブハウスのドリンクカウンター近くで大荷物を傍らにMacを広げ、妄想を始めました。

 

しばらくして、特典会が始まる間際になって、やっと決め手となるアイディアが浮かんできました。時間と人目を気にしながらも続けた妄想チャレンジにより、難関突破の道筋が見えてきたのでした。

 

すぐさまアドミの責任者の方にチャットし、とりあえず壁を超えられそうだという旨を報告しました。このあと取り得る施策を相談しつつ、意気揚々と推しグループの特典会に臨みました。そして、一定の方向性が見え、推し活を終えて会場を後にするや否や、頭の中はアイドルのことではなく「さらに次の一手でどうするか?」でいっぱいでした。

 

移動と帰省の最中での脳内設計

 

 

プロトタイプとしてはそこまでで良かったのですが、今後の展開で考えなければいけない課題は残っていました。帰省のためにたどり着いた東京駅でも、新幹線の発車時刻までの間、待合室でMacを広げてはAIと対話しつつ作戦を練ります。新幹線の車内でも、目的の駅までのギリギリまで、考えに考え尽くしました。トンネルに入ると、テザリングしているスマホの電波が不安定になり、AIが反応せず開発が止まり、さらにはAIの使用上限に達して、手が止まってしまったのでした。それでも思考は止めず、帰省の間中、ずっと暇さえあれば開発のことに思いを巡らすこととなりました。

 

最終日の昼には帰京。自宅に着いてしばらくして、夜の段階では、骨組みが作れるほどに情報整理ができました。再度、進捗をアドミに一報を入れたところで、私とAIによる脳内設計は終わりを告げたのでした。

 

挫折とAIとの共闘、そして実装へ

翌朝以降、MacだけではなくWindows版への移植を開始。すると、まもなくして衝撃が走ります。当初使用していた、スマホ・PC間のデータ転送ライブラリが、Windows環境をサポートしていないことが判明。まさに痛恨の手戻りでした。本当はすぐにでもアドミの方にプレゼンしたかったのですが、他日程と重なっていて予定が合わず、そもそも重大な課題に直面したために、しばらく調整の期間が必要となりました。

 

一進一退を繰り返しながら、仕事の合間を縫って開発。昼ごはんを左手に抱えて、右手では打ち込みを続ける毎日。どうしてもスマホとPCとの間の通信が上手くいかない。一度は完成に近づいていたはずのMac版も、Windows版の仕様変更に伴い再構築。スクラップ・アンド・ビルドを続けました。

 

通信が完結できないと、出席受付をしたデータをスマホからPCに取り出すことができないので、本当に重要な肝の部分。一昔前だと、Googleで同様の事例を検索しては、真似して解決策を探るのが一般的でした。でも、AIというツールを手にした私は、まるで熟練のアシスタントと対話するかのように生成AIと会話を重ね、何千回以上ものキャッチボールを繰り返して、少しずつバグを潰していきました。

 

勝手に任命を受けてから、ちょうど1週間。金曜日の早朝に、やっとWindows版も含めた安定動作の目処が立ったのでした。早速、アドミのスケジュールを押さえ、週明け月曜日にプレゼンをする運びとなりました。

 

待ちに待ったお披露目……となるはずのプレゼン本番。

私自身のIDカードから取り出した情報が、もしかするとアドミでは把握していないデータだった可能性が浮上しました。加えて、満を持して完成したと思っていたWindows版の重大な不具合も判明。

 

さすがに、執務室のセキュリティパッケージとしても使われているIDカードなので、秘匿された仕様があるのかもしれないとなり、バグのことも含めて、一気に振り出しに戻ったのでした。

 

ただ、この事態は、実は織り込み済みでした。バグというのは単純にコーディングミスだと分かり、筋道立てて修正を始めます。そしてカード情報が管理外であった場合の第二の案は、まさに頭の中で整理していた想定内の内容でした。現実的な運用のことも考え、すぐに機能拡張と修正に当たります。

 

その翌々日の夜、修正も終わり、再度、プレゼンの機会をもらうことに。お互いに忙しい中だったので、その週の金曜日に再検証の時間をいただくことになりました。

 

当日はデモをしながら運用手順を一通り説明し、注意点等を伝えた上で、無事、簡単な動作検証もできました。あとはアドミに実機を預け、12月のMRTでの導入判断を待つのみとなりました。

 

最後の難関、そしてリリース

 

そう言いつつ、エンジニアとしては、ここをもっとこうしたいとか、ここはこうじゃなきゃダメとか、いろんな不備が気になり、こっそりと改良を続けました。

 

そんな中で、お忙しいアドミの責任者の方に代わって、この受付システムの運用は某氏に引き継がれることになったとの連絡を受けます。12月のMRTまで、あと1週間と迫る金曜日の夕方でした。

 

仕事も立て込んでいたので、お互いの予定を合わせて翌週の火曜日にアポを取りました。こうして、アドミに準備していただいた受付用のスマホ端末に、今回開発した受付アプリのインストールを進める運びとなったのです。

 

某氏に、これまでに行った改良を説明しつつ、動作確認を行います。すると、台帳が存在しないと思われていたIDカード情報もリストが発掘されたようで、全社員のデータ打ち込みも完了しており、全ての準備が整った状態で待ち受けてくれていました。

 

正直、胸をなで下ろしました。

 

いったん正常動作を確認したのち、自席に戻ります。そうやって安堵したのも束の間。端末準備を始めていた某氏から、不穏な報告が入ります。IDカード読み取り時に、意図しない音が鳴る、と。

 

このアプリ、読み取り成功時と、そうでない場合とで音を鳴り分けする仕様となっており、聞き分け用のサンプル音源を提供したのですが、そのサンプル通りの音ではなく、エラー発生のような音がするという旨の問い合わせでした。

 

実物を聞かせてもらうと、確かに意図しない音が鳴っている。

 

でも、当初から準備していた1台では同様の音は鳴らず、期待したサウンドが再生されます。追加で準備いただいた端末2台だけで起きる不具合。いや、単純な不具合じゃない。読み取りや受付記録の挙動は正常ではあるものの、音だけがおかしい。

 

某氏と一緒に格闘しながら、ある発見をしました。「マナーモード」の設定の違いです。

 

その点をきっかけに調べてみると、どうやらIDカードの読み取り時、システム音として必ず鳴る音があることが分かりました。つまり、私が用意したサウンドとは別に、システム音が重なって鳴っていたのでした。すぐに修正にかかりますが、どうしても消せません。

 

そうです。このシステム音は、日本のスマホにおけるカメラのシャッター音と同様の理由からか、通常の手続きでは消せない音なのでした。正常動作するように見えていた1台との比較で気づいたのですが、マナーモードの時にだけ、システム音が鳴らないようにできるということまで突き止めました。

 

そのことに気づいてからは、受付動作中に自動でマナーモードへ切り替える実装に変更することで解決しました。そして、受付終了やアプリ終了後には元のマナーモード設定を復元するような作り込み。

 

長かった調整も佳境を迎え、一通りの準備が整ったのでした。MRTまで3日。実際に使えるのか、正直心配になりながらも、アプリのデビューの瞬間が刻一刻と近づいていきます。

 

エピローグ

 

しかし、MRTの受付が始まった、まさにその時に、私はIDカードをかざすことはありませんでした。勃発する緊急対応に追われ、結局、12月のMRTには顔を出すことができず。

 

MRT後の懇親会も、わずかな時間しかいられなかったのですが、アドミの方から結果を聞く限り、特に大きなトラブルはなく終えたとのことでした。列の滞留もなく、スムーズに受付ができたとの声をいただきました。

 

そして肝心の出席データの吸い出しですが、こちらも某氏が対応してくださり、無事PCへの転送ができたとのことでした。アドミの集計の手間なども省くことができ、当初の目論見通りの結果が得られたようで、何よりです。

 

1ヶ月という怒涛の開発は、こうして幕を閉じました。今後も、機会があれば業務改善に寄与する開発を推進できたらいいな、と思っています。

 

みなさんも、AIを活用した開発、始めてみませんか?

 

技術要素メモ

- NFCカード(社員証)

- Androidスマートフォン(受付端末)

- Windows / macOS 両対応

- 生成AIによる設計・デバッグ支援

- 社内向けPoC/業務改善ツール

 

【余談】推しグループについて

強くてニューゲーム。
前身グループも含めると12年活動しているアイドルグループ。2024年2月4日に今のグループ名に改名して再デビュー。

格好いい系の楽曲を中心に、完全生歌のパフォーマンスが特徴。さらに、生演奏さながらのマニピュレータによるサウンドでのライブは圧巻。

--------------------------------------------------------------------------------------------------

「主体的に業務を行い自身の幅を広げたい!」といった方は人事制度についてもお伝えしますので、[採用情報](https://mobilus.co.jp/recruit)のページからカジュアル面談(オンライン)をお申込みください^^

エンジニアがチーム異動して変わったこと、取り組んだこと

はじめに

こんにちは。Engineering DivisionのMASA.SEです。

主にバックエンドの開発がメインですが、フロントエンド開発もたまにやってます。

 

モビルスに入社し数年が経過しましたが、
入社時からずっとコンタクトセンター向けの有人チャットサービスの開発を携わってきましたが、
最近、チーム異動があり、自治体向けのLINE配信サービスの開発に携わることになりました。

今回はチーム異動を経験して感じたことや気づきについて書きたいと思います。

 

新しいプロジェクトへ参画するときの不安

新しいチームへ異動するにあたり正直、以下のような不安がありました。

  • 新しいチームで自分がしっかり成果を出せるのか

  • チームの雰囲気や進め方に馴染めるのか

  • 今までの経験が活かせるのか

こうした不安は誰でも感じることだと思います。最初の一ヶ月はとても緊張しました。

新しいチームで取り組んだこと

新しいチームでメンバーとして戦力として認められるよう以下を実践しました。

(自分の経験上、新しいプロジェクトメンバーにやってもらいたい動きを想像しました。)

• 小規模な案件でもしっかり成果を出し、成功体験を積み重ねる

自分の担当部分を着実にやる

• わからないことはしっかり聞く。「早く戦力になりたい」という真摯な姿勢、受け身でない自走しようとする姿勢を周りに伝える

 

またできるだけナレッジの拡充にも取り組みました。

例えば、新しく参画したプロジェクトでは、エンジニアはローカル環境構築は必ずといっていいほど行いますが、ローカル環境の構築手順ドキュメントのアップデートも行いました。新メンバーが入るときは、ナレッジをアップデートするいい機会です。次回、新しいメンバーが入った際、少しでもお役に立てればと思いました。

 

担当製品の開発環境が変わることについての考察

チーム異動により、これまで担当していた製品とはまったく異なるアーキテクチャ・開発環境を扱うことになりました。


社内全体でアーキテクチャ選定を統一できることが理想ではありますが、現実的には非常に難しいと感じています。

理由としては、
    •    社内全体で技術選定を統一するには、管理コストが非常に高い
    •    言語やインフラ環境のトレンド変化が激しく、数年単位で廃れる技術も珍しくない
    •    統一のために「枯れた古い技術」を使い続ける選択肢もあるが、エンジニアとしてのモチベーションが上がりにくい

などが挙げられます。

そのため、組織として技術の多様性を受け入れつつ、個々のチームが最適な選択をしていく柔軟さが求められていると感じています。

 

チーム異動で変わること

開発業務において、チームやプロジェクトの異動によって「仕事の進め方」が大きく変わることは珍しくありません。

 

チームにより、担当製品、チームメンバー、PM、ディレクターといった要素によって、日々の開発スタイルや業務フローが異なるためです。

具体的には、次のような違いが生じます。

  • 開発環境の違い

プロジェクトごとに開発環境の構築方法が異なります。

新しい製品を担当する際は、まずローカル環境のセットアップから始めることが多く、Dockerを利用するかどうかでも構成が大きく変わります。

使用言語やツールにもそれぞれ癖があり、まずは環境に慣れることが第一歩となります。

 

  • デプロイ方法の違い

プロジェクトによって、デプロイの進め方にも違いがあります。

ローカル環境、テスト環境、本番環境とでデプロイ方法が全く違うこともしばしばです。

インフラチームが一括で対応するケースもあれば、開発側が主導して行うケースもあります。

 

  • チケット管理・設計の進め方の違い

チケット管理のルールや工程の区切り方もプロジェクトによって様々です。

設計書をしっかり作成するプロジェクトもあれば、ドキュメントを最小限に留めるケースもあります。

設計をPMが主導するか、開発者が行うかもプロジェクトごとに異なります。

  

  • 担当範囲の違い

開発現場によって、バックエンド選任、フロントエンド選任など、担当がきれいに分かれていることもあれば、両方担当したり、場合によっては、インフラ領域も担当したりするなどさまざまです。

  • ナレッジ共有の成熟度の違い

ナレッジが体系的に整理されているプロジェクトもあれば、情報が点在しているプロジェクトもあります。

そのため、開発環境や仕様に関する情報の探し方も、それぞれのプロジェクト文化に合わせて工夫が必要です。

 

チーム異動をしてよかったこと

環境ががらっと変わり、色々戸惑うこともありましたが、

チーム異動は、自分の成長につながる良い機会だと感じています。

 

異動先の製品が、極端にレガシーな開発環境や非効率な開発手法でやっていない限り、得るものは多いと考えてます。

 

新しいチームでの業務は、まるで社内転職のような感覚で、これまでのやり方や考え方を見直すきっかけになります。

 

また、新しい技術や開発プロセスに触れることで、スキルアップにもつながり、仕事に対する新鮮な刺激を得ることができました。

 

 

1人1人がキャリアの幅を広げられるように、モビルスの開発組織では柔軟な組織異動ができる体制を構築中です。

 

 

--------------------------------------------------------------------------------------------------

「様々な技術に触れてスキルアップをすることに興味がある!」といった方は人事制度についてもお伝えしますので、[採用情報](https://mobilus.co.jp/recruit)のページからカジュアル面談(オンライン)をお申込みください^^

読書の秋におすすめ『人を選ぶ技術』──採用にも評価にも役立つ視点

採用面接で見るべきポイントまとめ

(書籍『人を選ぶ技術』より)

こんにちは。Engineering Divisionの MIYA.TOMO です。

読書の秋、みなさんはどんな本を手に取っていますか?
私は最近、小野壮彦さんの『人を選ぶ技術』を読みました。

この本は、採用や人材育成に携わる人にはもちろん、マネージャーとして部下を評価する立場の人にもとても参考になる一冊です。
本全体では幅広い示唆が語られているのですが、この記事では特に「採用面接や評価で使える評価軸」に絞って要点をまとめました。

これから面接を行う方や、部下の成長を支援する立場にある方にとって、日々の判断に役立つヒントになれば幸いです。

※本書はここで紹介している「評価軸」の話以外にも、採用面接の進め方や人を選ぶ技術の高め方など幅広い学びが詰まっています。 興味を持った方はぜひ手に取って読んでみてください。


基本的な評価の考え方

人の評価を行う際の基本的な考え方は以下の通りです。

  • 必ずファクトベースで判断する → 考えや意見ではなく、実際の行動や経験を確認すること
  • 「いいことばかりの人」はいない → 全てが良く見える場合、準備や飾りの可能性が高い
  • 可能であればスコアリングする → 言語化できることが望ましい

採用の評価軸

前提として、階層が深くなればなるほど変りにくい特性です。そのため、より深い階層を知ることが良いとされます。

(その他に、見た目が心地いい人もプラス評価として認めている点が面白いなと思いました。実際にパフォーマンスが高いというエビデンスがあるとか。)

1階:経験・知識・スキル

エンジニア採用の観点ではいわゆる技術力と呼ばれる部分。履歴書や職務経歴書など、見えやすい一方で変わりやすい部分でもあります。

1階部分だけで評価してしまうと、技術力が高くても、周囲に対して攻撃的であった場合に全体のパフォーマンスを下げてしまいます。また、今後の成長の伸びしろを判断するためにも、さらに深い階層まで評価する必要があります。

地下1階:コンピテンシー

人の考え方を表すもので、成果を出すための行動特性。どれが強いかを判断することで今後の行動予測にも利用できます。

  • メイン:成果思考、戦略思考、変革思考
  • サブ:顧客思考、市場思考、多様性思考、協働、人材育成、チーム運営

成果思考

  • 課せられた目標をどのように捉えているか。ノルマが課せられた時の行動をみる。
    • 低:「難しいとやめてしまう」
    • 中:「絶対にやり遂げようとし、目標をなんとか達成しようとする」
    • 高:「目標超えが当たり前。早期から逆算して動き、目標を超える成果を出す事は当たり前と考える」

戦略思考

  • ビジョン達成のためにどのような方法を選択し、他の人たちと異なるやり方をするか。(以下の基準は役員採用の色が強いので、エンジニア採用であればもう少し下げてもいいと思いました)
    • 低:「自部門の戦略を立てられる」
    • 中:「自社全体の戦略を策定できる」
    • 高:「業界・産業全体の戦略を立てられる」

変革思考

  • 現状に満足せず、新しいことに挑戦し改善しようとしているか。(この説明は本書にも詳しく書かれていないので私の解釈です)
    • 現状を打破するために何が必要か
    • 変化の方向性がどうあるべきか
    • どのようにして人々を熱狂させて巻き込むことができるか

地下2階:ポテンシャル

エナジー(エネルギー)の強さ。声の大きさではなく、仕草や話し方から漏れ出るもの。

「この方は淡々と話しているようで、話し方に熱がこもっているな・・・」と感じた経験があるのではないでしょうか。そのことです。

好奇心(赤色)

  • 全てのポテンシャルを育む源泉
  • 新しい経験や知識、率直なフィードバックを求めるエナジー
  • 学習と変化への開放性
  • 新しいことを見つけてきて楽しそうに話し掛けてきたり、読書好きだったりすると、(私は)魅力的だなと思います

洞察力(青色)

  • 新しい可能性を示唆する情報を収集・理解するエナジー
  • 「なぜ、この修正するんだっけ?」に立ち返ってより良い別案を提案したり、「あれ、この部分って修正してないとこういう障害に繋がるよな・・・」と気付いたり

共鳴力(黄色)

  • 感情と理論を用いて、想いやビジョンを伝え、人々と繋がるエナジー
  • コミュ力とも違う気がしていて、こちら側に深く入ってくる感覚?があるような

胆力(黒色)

  • 大きな課題や困難を好み、立ち向かうエナジー
  • 逆境から素早く立ち直る力
  • (私はここが弱いかもしれない)

ポテンシャルのイメージ図


見ておいた方がいいリスク

「よっし、いい人が見つかった!」と思っても、1度、立ち止まって確認してみてください。

  • 誰しもが程度の差はあれどサイコパスの傾向を持っている
  • 肝心なのは、「どの程度でEvilに至るか」の閾値を確認すること

どなたでも自分のEvilが出てしまった経験をお持ちではないでしょうか?

この章を読んでいる時、私もあの時はあんなことをしてしまったなぁ、、、と、自然に過去の自分を振り返っていました。

Evilに至るリスクのタイプ

どのタイプに該当するかで、追い込まれた時にどのようなEvilが出てくるかを事前に予測することができるそうです。

  • 目標に向かうタイプ
    • 目標達成や成果を重視します
    • 他者操作、成果の横取り、過剰な要求を行う可能性があります
  • 人間関係に向かうタイプ
    • 人間関係を重視します
    • 自己アピール、褒めて欲しい、他者依存・責任転嫁を行う可能性があります
  • あるべき姿に向かうタイプ
    • 正しさを重視します
    • 距離を置く反応、安全回避を行う可能性があります

まとめ

面接や評価の場では、経験やスキルだけでなく、その人の思考やポテンシャル、周囲への影響力も見極めることが重要です。本書で紹介されている観点を少しでも日々の判断に活かせれば、より良いチームづくりにも繋がることと思います。読書の秋、ぜひ手に取ってさらに深い学びを得てみてください。

モビルスではオンラインでのカジュアル面談も行っています。
事業内容や開発方針をより詳しく知りたい方はぜひ採用情報のページをご覧ください!

Go言語でPDFを作ろう

こんにちは!Engineering Divisionに2026年度新卒として入社予定のshimizuです。現在は内定者インターンとしてプロダクト開発に携わっており、先日、請求書作成機能のアップデートを担当しました。

これまで利用していたPDF作成モジュールが開発終了となり、要件を満たす代替モジュールも見つからなかったため、従来とは異なるアプローチで実装する必要がありました。

本記事では、その際に採用した新しい実装方法についてご紹介します。

背景

私の担当するサービス "MOBI VOICE" では、お客様に対して請求書を発行する機能を用意しています。 これまで、この請求書の作成方法として 1. 内部で請求書のHTMLを作る 2. 作ったHTMLをPDFに変換する という工程を踏んでいました。

Go言語におけるHTMLからPDFへの変換ライブラリとしては、wkhtmltopdfが一般的に用いられています。 このライブラリの特徴として、内部でWebkitを利用していたことから、一般的によく使われるブラウザ(chrome, microsoft edgeなど)とほぼ同じような見た目での変換が可能で、これまで広く活用されており、MOBI VOICEでも請求書発行機能において利用していました。

しかし、wkhtmltopdfは残念ながら現在、開発終了となっており、GitHubリポジトリはアーカイブされてしまいました。 一応現在も利用可能ではあるものの、今後セキュリティアップデートはされません。互換性などの予期せぬ問題が生じる可能性があり、開発元もwkhtmltopdfを利用する際は自己責任で利用するよう呼びかけています。

このままでは、MOBI VOICEのサービス安定運用に支障をきたしてしまいます。 そこで、wkhtmltopdfの利用を取りやめ、別の方法で請求書発行機能を維持することにしました。

代替候補

wkhtmltopdf から移行する候補として、以下の5つを検討しました。

候補 特徴 利用方法
xhtml2pdf ReportLabを活用 シンプルな描画に最適
レンダリングが少し古い
コマンドライン
weasyprint xhtml2pdfより新しい レンダリング精度○
依存ライブラリが多い
コマンドライン
go-rod ヘッドレスブラウザを操作 レンダリング精度◎
CPU使用量に懸念
直接記述
gotenberg Chromiumエンジン使用 レンダリング精度◎
独立したサーバーを立てる必要がある
API経由
gopdf HTML変換ではなく直接PDFを作る
既存設計の見直しが必要
直接記述

今回は、これらのうちgopdfを採用しました。 大きな理由は、もともとwkhtmltopdfの利用により、PDF生成のためだけにHTMLを生成・管理するというプロセスが存在し、不必要なオーバーヘッドになっていたことです。

gopdfによりHTMLを介さず直接生成できるようになり、依存ライブラリの削減・CPU使用量の低減を同時に実現しました。

gopdfで実装してみよう

ここからは、gopdfの基本的な使い方と、gopdfにある機能のうちいくつかの実装方法について、具体的なコードを交えて解説していきます。

gopdfのリファレンスはこちら

https://pkg.go.dev/github.com/signintech/gopdf#section-documentation

事前知識

gopdfでは、基本的に座標を用いてPowerpointのようにpdfを作成していきます。 その際、中学数学のように座標を指定して描画していくのですが、座標系に特徴があります。 左上が原点で、X軸は右向き正、Y軸は下向き正です。Y軸だけ中学数学と違いますね...。

gopdfでの座標系

なお、この座標系は、Pillow scikit-image Java AWT Windows GDI などの、コンピュータグラフィクスの世界においてはよく採用される方式です。 日本語や英語などの言語が、左から右へ、上から下へ文字を書いていくのと同じですね。

なお、gopdfにおいては、A4用紙のサイズは595 x 842 ptに設定されています。つまり、

  • x座標:0 ~ 595
  • y座標:0 ~ 842

となります。 これを超えると外にはみ出してしまいます。

実装

事前準備としてgopdfをインストールします。

go get -u github.com/signintech/gopdf

gopdfをimportします。

import (
    "github.com/signintech/gopdf"
)

まず、PDFオブジェクトを用意します。これを利用してPDFを作成していきます。 サイズはA4に設定します。

// pdfオブジェクト作成
pdf := gopdf.GoPdf{} 
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) // A4サイズは 595 x 842 pt

続いて、フォント設定を行います。 gopdfは標準でフォントを持たないため、ttfファイルが必要です。 今回は、IPAex明朝フォントを使用します。事前にttfファイルをダウンロードしてください。

// フォントファイルの読み込み
fontFilePath := "font/ipaexm.ttf" // 環境に合わせて設定してください
fontName := "ipaexm" // 環境に合わせて設定してください
err := pdf.AddTTFFont(fontName, fontFilePath)
if err != nil { 
    log.Fatalf("フォントの読み込みに失敗しました: %v", err) 
}

フォントスタイルは以下から選べます。 - U (underline 下線) - B (Bold 太字) - I (Italic 斜体) - "" (空欄 通常スタイル 今回はこれを採用)

// 読み込んだフォントを使用
fontSize := 14
fontStyle := ""
err = pdf.SetFont(fontName, fontStyle, fontSize)
if err != nil {
    log.Fatalf("フォントの設定に失敗しました: %v", err)
}

やっとpdfに文字を書く準備ができました。ページを追加し、文字を書きましょう。 pdf.Cellの第一引数で文字を回転させることができますが、今回は割愛します。 ここで使う座標は、先に説明した「左上原点・X軸右向き正・Y軸下向き正」です。

// ページを追加
pdf.AddPage()

// テキストを配置 
pdf.SetX(100) // X座標 (左から100pt)
pdf.SetY(100) // Y座標 (上から100pt)
pdf.Cell(nil, "Hello, World!") // テキストを描画

最後に、pdfを書き出しましょう。

// PDFファイルを保存
outputFilePath := "output.pdf"
err = pdf.WritePdf(outputFilePath)
if err != nil {
    log.Fatalf("PDFの保存に失敗しました: %v", err)
}

log.Println("PDFが正常に作成されました:", outputFilePath)

ここまでのコードを全て書くと、以下の通りになります。

package main

import (
    "log"

    "github.com/signintech/gopdf"
)

func main() {
    // pdfオブジェクト作成
    pdf := gopdf.GoPdf{}
    pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4}) // A4サイズは 595 x 842 pt

    // フォントファイルの読み込み
    fontFilePath := "font/ipaexm.ttf" // 環境に合わせて設定してください
    fontName := "ipaexm"              // 環境に合わせて設定してください
    err := pdf.AddTTFFont(fontName, fontFilePath)
    if err != nil {
        log.Fatalf("フォントの読み込みに失敗しました: %v", err)
    }

    // 読み込んだフォントを使用
    fontSize := 14
    fontStyle := ""
    err = pdf.SetFont(fontName, fontStyle, fontSize)
    if err != nil {
        log.Fatalf("フォントの設定に失敗しました: %v", err)
    }

    // ページを追加
    pdf.AddPage()

    // テキストを配置
    pdf.SetX(100)                  // X座標 (左から100pt)
    pdf.SetY(100)                  // Y座標 (上から100pt)
    pdf.Cell(nil, "Hello, World!") // テキストを描画

    // PDFファイルを保存
    outputFilePath := "output.pdf"
    err = pdf.WritePdf(outputFilePath)
    if err != nil {
        log.Fatalf("PDFの保存に失敗しました: %v", err)
    }

    log.Println("PDFが正常に作成されました:", outputFilePath)
}

応用編

これまでの内容では、1枚のpdfに文字を書くだけの最小限のやり方をご紹介しました。 ここからは、さらに様々な要素を組み合わせてpdfを作る方法をご紹介します。

・線をかく:

// 応用:線をかく
pdf.SetLineWidth(1.5)       // 線の太さを設定
pdf.Line(50, 100, 545, 100) // 始点(50, 100)から終点(545, 100)まで線を引く

・画像を配置する: pdf.Imageの第四引数で画像を回転させることができますが、今回は割愛します。

// 応用:画像を配置する
imageFilePath := "image/sample.png"           // 環境に合わせて設定してください
err = pdf.Image(imageFilePath, 100, 150, nil) // X座標100pt, Y座標150ptに画像を配置
if err != nil {
    log.Fatalf("画像の配置に失敗しました: %v", err)
}

・バイナリデータの画像を配置する: 事前に何らかの方法でバイナリ画像データを用意します。 ここでは事前にHTTPリクエストを出し、response.Bodyから取得をする想定です。

// 事前に何らかの方法でバイナリ画像データを用意
// ここではresponse.Bodyから取得をする前提
URL := "https://example.com"
response, err := http.Get(URL)
if err != nil {
    log.Fatalf("画像の取得に失敗しました: %v", err)
}
defer response.Body.Close()
binaryImageData := response.Body

// 画像データを読み込み
imageData, err := io.ReadAll(binaryImageData)
if err != nil {
    log.Fatalf("画像の読み込みに失敗しました: %v", err)
}

// PDFに追加
imageHolder, err := gopdf.ImageHolderByBytes(imageData)
if err != nil {
    log.Fatalf("画像の追加に失敗しました: %v", err)
}
err = pdf.ImageByHolder(imageHolder, 100, 150, nil)
if err != nil {
    log.Fatalf("画像のpdf貼り付けに失敗しました: %v", err)
}

・表をかく: 表に関しては、あらかじめ列数を定義しなければならない点に注意です。 ここではtableMaxRowsで定義しています

// 応用:表をかく
tableStartX := 50.0    // 表の開始X座標
tableStartY := 400.0   // 表の開始Y座標
tableRowHeight := 20.0 // 行の高さ
tableMaxRows := 5      // 最大行数
table := pdf.NewTableLayout(tableStartX, tableStartY, tableRowHeight, tableMaxRows)

// 列を追加
table.AddColumn("列1", 200, "left")   // 列1の幅を200ptに設定
table.AddColumn("列2", 150, "center") // 列2の幅を150ptに設定
table.AddColumn("列3", 100, "right")  // 列3の幅を100ptに設定

// データを追加
for i := 0; i < tableMaxRows; i++ {
    row := []string{
        "データ1",
        "データ2",
        "データ3",
    }
    table.AddRow(row)
}

// 表を描画
table.DrawTable()

おわりに

本記事では、開発が終了した wkhtmltopdf の代替として gopdf を採用し、Go言語で直接PDFを生成する方法について解説しました。 Go言語でドキュメントを生成する際の選択肢として、ぜひ gopdf を検討してみてはいかがでしょうか。

今回紹介した機能以外にも、図形の描画、pdfパスワードの設定、ヘッダー・フッター機能、すでに作ったpdfを取り込んで編集など、様々な機能があります。 ぜひ試してみてください。


モビルスではオンラインでのカジュアル面談も行っています。
事業内容や開発方針をより詳しく知りたい方はぜひ採用情報のページをご覧ください!

クリーンコード:クリーンな関数を書くためのベストプラクティス

はじめに

こんにちは!SaaS Product Division、MOBI AGENTでフロントエンドを担当しているKarkee Niksanです。

関数の動作を理解するのに3秒以上かかるようでは、その関数はリファクタリングする時期かもしれません。関数の読みやすさ・わかりやすさの品質は、それを理解するまでに要する時間に反比例します。

複雑な関数はバグの温床となり、変更を困難にし、新人開発者のオンボーディング(立ち上げ)も遅らせてしまいます。コードは書く回数より読まれる回数の方が圧倒的に多いので、クリーンな関数を書くことに時間を投資するのは長期的に見て最も価値のある取り組みの一つと言えるでしょう。そこで以下では、関数をクリーンに保つための7つのヒントを紹介します。各セクションでは TypeScript の実用的なコード例を示しながら、そのポイントを解説します。

関数はできるだけ短くする

ソフトウェア業界のレジェンドであるアンクル・ボブ(Robert C. Martin)

The first rule of functions is that they should be small.

The second rule of functions is that they should be smaller than that.

関数の第一法則は小さくあること。第二の法則はそれよりさらに小さくあることだ

と述べています。

関数は一つのことを行い、それをしっかり行うべきです。では理想的な関数の長さ(行数)はどれくらいでしょうか? 実は明確な基準はありません。5行で十分なこともあれば、単一の責務を果たすのに50行が必要な場合もあります。

重要なのは文脈に応じて判断し、常に状況に合わせた最適な長さを選ぶことです。ルールに縛られすぎず、決して独善的にならないようにしましょう。ポイントは関数をなるべく小さく保つよう努めつつ、細かく分割しすぎてコード全体がかえって見づらくならないようバランスを取ることです。

上記の例では、一つの巨大な関数 processOrder が、複数の小さなヘルパー関数に仕事を委譲することで、読みやすく整理されています。このように関数を小さく保つことで、一目で処理の流れが追える明瞭なコードになります。短い関数は再利用もしやすく、テストもしやすいという利点も生まれます。

関数には適切な名前を付ける

適切な関数名は、コードの理解を深めます。関数が何をするのかを明確に示す名前にすることで、コード全体の処理や目的が把握しやすくなります。

関数名を考える際、以下の4つのポイントが役立ちます:

  1. 意図を明確にし、ビジネスに即した名前を使うこと。 コードが顧客の言葉で書かれていなければ、それはユーザーの課題に焦点を当てられていないことを意味します。

  2. 関数名には動詞や動詞句を用いること。 名詞や形容詞だけの名前では、その関数が何をするのかが明確に伝わらない場合があります。

  3. チーム内で統一された命名規則を使用すること

  4. 同じ概念に対して異なる用語を使わないこと。 用語の不一致はコードを不整合にし、開発者同士の混乱を招きます。代わりに、同じ概念には一貫して同じ単語を使用しましょう。

関数の引数の数を制限する

理想的には、関数の引数の数は0個が望ましいと言われます。引数が多すぎる関数はそれだけで複雑さが増し、テストもしにくくなってしまいます。

どうしてもそれ以上の情報を渡す必要がある場合は、関連するパラメータ同士をオブジェクトなどのデータ構造にまとめて渡すことを検討してください。そうすることで関数シグネチャ(定義)が簡潔になり、可読性も向上します。

この例では、5つもの引数を取るregisterUser関数を、UserInfoオブジェクト1つだけを受け取る形に変更しています。関連する値をオブジェクトにまとめることで、関数定義が簡潔になり、呼び出し側のコードも読みやすくなります。引数の数が多い場合は、設計を見直して本当にその数が必要か検討するか、上記のようにオブジェクト化して整理しましょう。

早期リターンでネストを減らす

関数内で入れ子(ネスト)の深い if 文を多用するのは避けましょう。ネストが深くなると余計なインデント(字下げ)が増え、コードの見通しが悪くなり保守性も下がります。

代わりに条件を反転させて、ガード節(早期リターン)を使うと良いでしょう。それによりコードの読みやすさが向上し、おまけに不要な else ブロックも排除できます。ネストを浅く保つことで、主要な処理の流れが埋もれずに済み、意図が把握しやすくなります。

このように、まず異常系や例外的な条件を冒頭でチェックして早めに関数から返すことで(ガード節による早期リターン)、後続の主要な処理部分のネストを浅く保つことができます。上記の良い例では、正常処理のコードが深い位置に追いやられることなく一直線に読み進められます。ネストが減ると見通しが良くなり、不要なelseも省けてコードがすっきりします。

副作用のない純粋関数を書く

「純粋関数 (pure function)」とは何でしょうか? それは、同じ入力に対して常に同じ結果を返し、副作用を持たない関数のことです。言い換えれば、出力が入力にのみ依存し、隠れた状態変更や外部への影響を一切及ぼさない関数です。

純粋関数には主に次のような利点があります。

  • コードの挙動が予測しやすくなる(常に同じ入力→同じ結果)
  • テストが容易になる(副作用がないため検証が単純)
  • スレッドセーフで並列実行しやすくなる(どの順序で実行しても結果が変わらない)

すべての関数が読んでその通りの動作をするのであれば、それはクリーンなコードと言えるでしょう。純粋関数を増やすことは、その理想に近づく一つの方法です。

上記のincrementCountは、グローバル変数countを変更し、さらにコンソール出力も行っています。これは関数の外で副作用を起こしており、呼び出すたびに挙動が変化するため純粋関数ではありません。一方、addは与えられた入力からただ結果を計算して返すだけで、外部の状態を一切変更しません。常に同じ入力には同じ出力を返し、プログラムの他の部分に影響を及ぼさないため純粋関数と言えます。純粋関数を増やすことでコードの予測可能性と信頼性が高まり、テストもしやすくなります。

ブール値のフラグ引数を避ける

関数の引数にboolean(真偽値)を使うことは、しばしばコードの理解を難しくします。理由は2つあります。1つ目に、truefalse といった値だけでは呼び出し元からその意味を読み取れず、関数呼び出しの意図が不明瞭になることです。2つ目に、そのフラグに応じた挙動を関数内に持たせると、将来的に機能を拡張したい場合に柔軟性を欠くという問題があります。

例えば次の2つの関数呼び出しを見比べてみてください。setMode(true)setMode(false) —— この true/false が何を意味するか瞬時に判断できるでしょうか? 一方で setMode("dark")setMode("light") のように呼び出せば、コードを見ただけで意図が伝わるはずです。

このように、真偽値ではなくEnum(列挙型)や文字列リテラルのユニオン型を使って引数を定義すると、コードが自己文書化された(自己説明的な)ものになり、可読性が大きく向上します。小さな変更に思えますが、効果は絶大です。

setDarkMode のように真偽値フラグを取る関数では、呼び出し時に引数が true なのか false なのかだけでは、その意味を正確に読み取れません。上記の良い例のように、引数に列挙型(ここでは文字列リテラルのユニオン型)を用いることで、関数の使い方が自己説明的になります。例えば setMode("dark") と書けば、一目で「ダークモードに設定する」ことがわかります。このように、ブールフラグの代わりに意味のある型を使うことで、コードの明確さと拡張性が向上します。

コメントは控えめに使う

コードが理解しづらいからといって、安易にコメントを追加して説明しようとしてはいけません。冗長なコメントはクリーンでないコードの典型的な臭い(コードの悪臭)の一つです。コメントには次のような問題があることを念頭に置きましょう:

  • コメントはコードの変更に追従しづらく、容易に陳腐化して内容が正確でなくなる
  • コードから明らかなことを繰り返し説明する冗長なコメントが生まれがち
  • コメントだらけのコードは、結局細部まで読む人がいなくなる

コメントは「なぜ」そのコードを書いたのかを説明するには有用ですが、「何をしているか」の説明に使うのは最後の手段にすべきです。多くの場合、コメントの代わりに適切な関数名や変数名を付けることで、コード自体に意図を表現させることができます。覚えておきましょう: 詳細な説明が必要な長いコメントを書くくらいなら、詳細を盛り込んだ長い名前を関数に付ける方がずっと有益です。

上記の calc という関数名だけでは目的が伝わらないため、コード内に「2数の平均を計算する」というコメントが書かれています。しかしその代わりに、関数名自体を calculateAverage のように具体的にすれば、コメントがなくても意図が明確に伝わります。不要なコメントを減らし、コードそのものを自己説明的にすることがコメントを控えめに使うという意図です。もちろんコメントが完全に悪いわけではありませんが、本当に必要な場合に限定し、コードで表現できることはコードに託しましょう。

まとめ

プログラミングは単にコンピュータに命令を与えるだけでなく、他の開発者に「プログラムに何をさせたいのか」を伝える作業でもあります。ソフトウェア開発において関数をクリーンに保つことは、そのままコード全体の可読性と保守性を高めることにつながります。本記事で紹介したポイントを改めて整理すると次の7つになります:

  • 関数はできるだけ短くする – シンプルで一つのことに集中した関数にする
  • 関数には適切な名前を付ける – 意図が伝わるわかりやすい名前を選ぶ
  • 関数の引数の数を制限する – 引数はできれば3つ以内に抑え、必要ならオブジェクトにまとめる
  • 早期リターンでネストを減らす – ガード節を活用してコードのネストを浅く保つ
  • 副作用のない純粋関数を書く – 入出力に集中し外部状態を変更しない関数にする
  • ブール値のフラグ引数を避ける – 列挙型やユニオン型で意図を明示する
  • コメントは必要最小限にとどめる – コードそのもので「何をしているか」を語らせる

Node.jsでStreamを使ったZip圧縮方法

はじめに

こんにちは、SaaS Product DivisionのMITA.TOMOです。モビキャストというLINE配信のためのサービスを担当しており、普段は機能追加や保守を行っています。最近、モビキャストの機能追加でAmazon S3のファイル群をZip圧縮してダウンロードする要件がありました。

その際に実装した、Node.jsのStreamを使ったZip圧縮の実装方法をご紹介します。
Zip圧縮にはarchiverパッケージを使用しています。

※ 今回の要件ではWritableStreamの出力を使う必要がなくなったため、現在は以降に示すコードを使用しておりませんが、どなたかの一助になりましたら幸いです。
 (疎通確認はしておりますが、万が一、問題がありましたら適宜修正してください。)

課題

Amazon S3にアップロードされているファイル群を、Zip圧縮してダウンロードする要件がありました。 最もシンプルな手順は以下の通りです。

  1. S3のファイル群をローカルストレージのディレクトリにダウンロード
  2. ローカルのディレクトリをZip圧縮する

しかし、ECSやLambda環境などの場合、メモリやストレージに制限があります。

そのため、今回はNode.jsのReadableStream / WritableStreamを使ってZip圧縮する必要がありました。

パッケージ選定

  • jszip(GitHub:10k stars)
    メモリ上でファイル操作を行うため、 ファイルサイズが大きい画像や動画などを扱う可能性のある今回の要件では不採用としました。

  • zip.js(GitHub:3.6k stars)
    Browser環境を想定しているため、Node.jsのReadableStreamWritableStreamに対応していないので不採用としました。

  • yazl(GitHub:348 stars)
    軽量でシンプルなライブラリですが、開発が活発でない可能性があるので不採用としました。

  • archiver(GitHub:2.9k stars)
    ReadableStreamWritableStreamの処理が可能であるので採用しました。

archiverの特徴として以下の点が挙げられます。

  • js実装(ただし、DefinitelyTypedで型定義されているので型安全に使用可能)
  • Node.jsのバージョン互換のためにreadable-streamパッケージ※を利用
  • "end"イベントで終了を検知します

※ This package is a mirror of the streams implementations in Node.js 18.19.0.

実装

archiverを使用するとStreamでZip圧縮が可能です。

ただし、archiverはEventDrivenな実装なので扱いづらさを感じました。
そこで 以下のように async / await で利用できるようにWrapper Classを作成しました。

import archiver from 'archiver';
import { Readable } from 'stream';

/**
 * ZIP圧縮をするためのクラスです。
 * このクラスはInputStreamを追加し、それを圧縮したZIPファイルをOutputStreamに書き込みます。
 * @example
 * const zipArchiver = new ZipArchiver();
 * zipArchiver.addInputStream(inputStream, 'file1.txt');
 * zipArchiver.addInputStream(inputStream2, 'subdir/file2.txt');
 * try {
 *  await zipArchiver.writeZipToStream(outputStream);
 * } catch (error) {
 *  console.error('Error creating ZIP file:', error);
 * }
 */
export default class ZipArchiver {
  constructor() {
    // compression level 9:最も小さいZipサイズだが、最も時間がかかる
    this._archiver = archiver('zip', { zlib: { level: 9 } });
    this._inputObjectList = [];
  }

  /**
   * Zipに圧縮するReadableStreamを追加します。
   * @param {ReadableStream} inputStream - 追加するReadableStream
   * @param {string} filePathInZip - Zip内のパス
   * @returns {ZipArchiver} - Builderパターンっぽくthisを返してみました。
   */
  addInputStream(inputStream, filePathInZip) {
    this._inputObjectList.push({ source: inputStream, filePathInZip });
    return this;
  }

  /**
   * ReadableStreamsからZIPを作成し、WritableStreamに書き込みます。
   * @param {WritableStream} outputStream - Zipファイルの書き込み先
   * @returns {Promise<void>} 実行結果
   */
  async writeZipToStream(outputStream) {
    return new Promise((resolve, reject) => {
      // error event の handling
      for (const { source } of this._inputObjectList) {
        if (source && source instanceof Readable) {
          // ReadableStreamの場合
          source.on('error', err => {
            // 入力ファイルが存在しないなどのエラーを考慮
            reject(err);
          });
        }
      }

      outputStream.on('error', err => {
        // 出力先のPathが存在しないなどのエラーを考慮
        reject(err);
      });

      this._archiver.on('error', err => {
        reject(err);
      });

      // 処理完了時 event で resolve
      this._archiver.on('end', () => {
        const zipBytes = this._archiver.pointer();
        resolve(zipBytes);
      });

      // 入力ファイルの登録
      for (const { source, filePathInZip } of this._inputObjectList) {
        this._archiver.append(source, { name: filePathInZip });
      }

      // ZIPファイル出力先の設定
      this._archiver.pipe(outputStream);

      // archive構造への追加を防止(おまじないと思っていい)
      this._archiver.finalize();
    });
  }
}

さいごに

モビルスでは現在、Goのバックエンドエンジニアを募集しています。

プログラミングからインフラまで幅広い分野に携わることができます。
新しい技術にチャレンジしたい方のご応募をお待ちしています!

(FY25)【正社員】CXプラットフォーム バックエンドエンジニア/202502 / モビルス株式会社

Pub/Subパターンで変更容易性を高めた話

Saas Product DivisionのMIYA.TOMOです。

今回は私が担当する製品の一部に「Pub/Subパターン」を適用した話について書きたいと思います。

背景

私の担当するモビキャストでは、LINE配信を行うサービスを提供しております。

例えば、

  • エンドユーザー様に属性値を設定し、セグメント単位でメッセージを送る機能
  • お友だちでないエンドユーザー様にも電話番号を指定してメッセージを送る機能(オプション機能)

など、お客様が保有する公式LINEアカウントのエンドユーザー様に、適切なメッセージをお届けするためのサービスです。

LINEを使用したサービスを開発するにあたり、欠かすことのできない技術のひとつに「Webhook」があります。
この度、モビキャストでもLINEのWebhookを受信するシステムを構築いたしました。

Webhookとは

システム内で特定のイベントが発生したとき、事前に指定されたURLにHTTPリクエストを送信する仕組みです。

LINEの場合、「友だち追加」「ブロック」「メッセージ送信」などのイベントがあります。
これらイベントが発生すると、Webhookの仕組みにより外部のシステムへ発生したイベントの内容を通知します。

Webhookのイメージ図

システム構成

前提として、システムアーキテクチャを検討する上でよく言われることですが、どのアーキテクチャにも一長一短があります。
トレードオフやシステムの展望を考慮した上で、アーキテクチャを選択することが大切です。

それでは、Webhookを受信するシステムの構成を見ていきましょう。

シンプル構成

はじめに、最もシンプルな構成を考えてみたいと思います。(今回はAWSを使用します)

シンプル構成

こちらの構成のメリットは以下のものがあります。

  • シンプルで分かりやすい
  • コストが比較的安い

一方、デメリットもいくつかあります。
ここでは、タイトルにもある「変更容易性」について言及したいと思います。

変更容易性(modifiability)とは

変更容易性とは、システムの変更や修正を容易に行える特性のことを指します。

システムを運用していく上で、機能追加や仕様変更など、システムへの変更は日常茶飯事です。
LINEのWebhookについても、さまざまな活用法が考えられます。

また、機能を増やすことと同時に、減らすことも容易でなければなりません。
では、シンプル構成ではどのような問題が発生するでしょうか。

シンプル構成の問題点

次のような改修を想定します。

  1. WebhookのイベントをDBに書き込みます。(=機能1)
  2. リリース後、別のシステムでWebhookのイベントを転送してほしいと言われました。(=機能2)

シンプル構成の場合、Lambdaに機能1を実装します。

リリース後、Lambdaに機能2を追加します。
このとき、機能1のソースコードに修正を加えるため、機能1に影響がないように配慮します。

シンプル構成 - 機能2を追加

しばらくして、DB構成の見直しに伴い、イベントを別のDBに書き込む必要がでてきました。

  1. Webhookのイベントを別のDBに書き込みます。(=機能3)
  2. 機能3が安定稼働したら、機能1を削除します。

機能3を実装するときには、機能1、機能2に影響がないように配慮します。
また、機能1を削除するときにも機能2、機能3に影響がないように配慮します。


このように、シンプル構成では改修のたびにデグレードのリスクが伴います。
また、機能2を別のチームで実装する場合、その改修の難しさはさらに上がります。

Pub/Subパターンの構成

ここまでの問題を解消するために、Pub/Subパターンを採用することにしました。
Pub/Subパターンで、機能1を実装すると以下の構成になります。

Pub/Subパターン - 機能1の実装

ポイントはAmazon SNSの動きです。

  1. Webhookを受信すると、Amazon SNSにメッセージを送信します。(Publish)
  2. Amazon SNSは、Publishされたメッセージを全てのSubscriberに送信します。(Subscribe)

では、ここに機能2を追加してみたいと思います。

Pub/Subパターン - 機能2の実装

Webhookの受信部分(Publishする実装)や機能1には変更を加えず、機能2を追加できます。
Subscriber(購読者)を増やすことで容易に機能追加が可能です。

また、修正や削除の場合も同様に、他の機能に影響を与えることなく変更できます。

まとめ

他の機能に影響を与えることなくシステムに変更を加えることができる「Pub/Subパターン」は、変更容易性が高い構成と言えるのではないでしょうか。

なお、LambdaでSubscribeすると、リトライや同時実行数による課題が発生することがあるため、緩衝材としてSQSでSubscribeする構成が一般的です。

アーキテクチャの検討は将棋のようなもので、課題を解消するために、あーでもない、こーでもない、を繰り返し、結果的にその形に辿り着きます。
冒頭にも書いたように、必ずこれが正解、という構成は存在しないため、今後のアーキテクチャ検討の選択肢の一つとして、頭の片隅に置いていただけたら幸いです。


モビルスでは、一緒に働く仲間を募集中です!
興味のある方は、ぜひ採用情報のページをご覧ください!