単純な実装でみんなの負担を減らした話 〜サイトカスタマイズ機能の開発物語〜

こんにちは、Webエンジニアの野川(@chan_naru_way)です。趣味は、Perplexity AIを触ってなんでも知った気持ちになることです。(最近はGensparkも面白い!)とはいっても、回答を鵜呑みにせずに自分の頭で考え続けたい、まだまだAIに使われたくない所存です。

徐々にパソコンのファンの風が心地よく感じる季節になってきました。季節の変わり目なので、みなさん体調に気をつけてくださいね〜。

今回は、Communeで提供している機能のひとつ「カスタムブロック」が、どんな課題を解決するために、どんな判断や開発を経て作られたのかを紹介します。ひとつの事例ですが、私たちの開発の雰囲気が伝われば幸いです。

はじめに 〜SaaS依存のカスタムブロック機能〜

Communeはコミュニティづくりを立ち上げから活性化までトータルサポートするコミュニティサクセスプラットフォームです。コミュニティサイトの管理者たちは、Communeを使ってサービスやコミュニティの世界観・魅力をユーザーに正しく伝えたいと思って日々試行錯誤しています。コミュニティを彩るためサイト上に「もっと自由にコンテンツを掲載したい!」と意見をいただくこともありましたが、当時のCommuneが持ち合わせていた機能(ホームバナーやカードリンク、投稿など)だけではコミュニティ管理者の素敵なアイデアを十分に表現できないことがありました。

そこで、2023年の3月ごろにコミュニティサイトをカスタマイズできる機能「カスタムブロック」の提供を開始しました。カスタムブロック機能とは、サイト上にあらかじめ指定した複数の箇所に任意のHTMLコンテンツ(HTML/CSS/JavaScript)を埋め込むことができる機能のことです。

当時はPoC版という立ち位置でWeb版のサイト(ブラウザのスマートフォン表示は含み、スマートフォンアプリは含まない)だけに機能を提供しており、実現手段に他社様のSaaSを使っていました。

当時のシステムや運用の流れはざっくりこんな感じです。

カスタムブロック機能(PoC版)のざっくり概要

カスタムブロック機能(PoC版)の運用フロー パターン1

カスタムブロック機能(PoC版)の運用フロー パターン2

コミュニティサイトの数や管理者の人数が増えてきたこともあり、カスタムブロックへの要望も多くいただくことになりました(とても嬉しいです!)。お客様側でコンテンツを制作しても、弊社側で設定する必要があり、設定を依頼すること、そしてコンテンツを修正するたびに再度設定を依頼するという、弊社だけでなくお客様側にも手間がかかってしまう状況でした…。さらに、Communeのスマートフォンアプリのサイトも、もっともっと自由に彩りたいという要望がどんどん高まっていました。

こうした背景から、カスタムブロック機能のあり方を見直す必要があると判断しました。そこで、この記事で紹介する取り組みが始まったのです。

内製化を決断!理由と判断基準

ソフトウェア開発では、新しく機能を開発する際に「外部サービスの活用」と「内製化」の選択肢があります。一般的に、以下のような考え方がベースにあると思います。

  • Proof of Concept (PoC) 段階では、外部SaaSを活用し、迅速に価値検証を行う。
  • 機能の価値が証明された後は、内製化を検討し、自由度の向上やコスト・UXの最適化を図る。

要はサクッと試して「いける!」となったら自分たちで作っちゃおう、ということです。カスタムブロック機能の開発においても、これらを意識して検討を進めました。

PoC版のカスタムブロック機能は、いくつか課題を抱えていました。

  • ネイティブアプリのコミュニティサイトのカスタマイズ性が低い(カスタムブロックがネイティブアプリ版のCommuneに対応していない)。
  • カスタムブロックにコンテンツを掲載するために、一定のコミュニケーションコストがかかる。また、このコストはカスタムブロックの利用者が増えるとともに右肩上がりで増える可能性がある。

同じコミュニティサイトでもスマホアプリユーザーが取り残されることになったり、スマホアプリこそ適したコミュニティを彩ることができなかったり。お客様が増えれば増えるほど、我々の作業も増える。嬉しいような、大変なような。 いずれも、PoC版で価値を一定示しつつも自由度が足枷になっている、と思えます。内製化も検討できそうなタイミングと捉えました。

課題を乗り越える方法をいくつか比較検討しました。重要なのは、課題を解決した上でコストを抑えられるかです。他社様のサービスを使うことはベストな解決策になることも多いですが、システムの一部が社外に依存する(社内で管理できない要素が増える)という考え方もあり、慎重に検討する必要があると私は思います。

選択肢 修正コスト 運用コスト その他の考慮点
他社SaaS継続 - ネイティブアプリ対応が必要
- 利用者増加に伴いコスト増加
他ツール/サービス移行 高(不明) 高(不明) - 実現可能性の調査が必要
- システムパフォーマンスの懸念
- セキュリティ管理の課題
内製化 低(設計次第) - 管理画面の新設が必要
- Web・ネイティブアプリ両対応が必要
- コミュニケーションコスト削減が可能

短期的な修正コストだけでなく長期目線で検討した結果、内製化に決めました。この決断は、すでにPoC版で提供している価値を保ちながら技術的な自由度を高め、また長期的なコスト削減も見込めるものとなりました。

カスタムブロック機能の開発

設計と技術選定

カスタムブロック機能には、主に3つの機能が必要になると考えました。

  1. サイト上のあらかじめ指定した箇所に対して、任意のコンテンツ(HTML/CSS/JavaScript、画像や動画など)を挿入できる機能
  2. コンテンツを表す各種情報(HTML/CSS/JavaScriptのスクリプト、画像や動画ファイルなど)をコンテンツ掲載箇所毎に保持する機能
  3. サイトの管理画面上でコンテンツを作成できる機能(エディタ機能やプレビュー機能)

コンテンツの表現に自由度を与えつつ、実装もシンプルになって開発工数も見通しが立ちやすい考え、iframeを選びました。また、iframeは独立したオリジンで提供されるため親サイト(メインのコミュニティサイト)とのセキュリティ分離がしやすく、リスクも想定しやすいので比較的スムーズに開発できるだろうと見込みました。

カスタムブロック機能(内製版)のざっくり概要

コンテンツを保持するためには、すでに社内で実績のあったCloud Storage (Google Cloud)を活用しました。カスタムブロック機能用にCloud Storageのバケットを用意し、Cloud Run (Google Cloud)上で動いてるCommuneのバックエンドAPIと接続(接続方法を後述する)しました。そして、コミュニティサイトの管理画面上にHTML形式のスクリプトの入力画面を用意し、そこでコンテンツを受け付けるようにしました。

※最初のリリースではHTML形式のスクリプト、要は文字列だけを受け付ける形にしました。なので画像や動画は入力画面から直接アップロードはできません。画像や動画を載せたい場合は、別途ファイルアップロードサービスで配信し、HTMLのimg要素やvideo要素などでURL指定してもらうようにしています。Communeでも他の機能の一部でファイルアップロード機能を提供しており、お客様にはこれを使ってもらっている現状です。今後は文字列だけでなくファイルアップロード/ディストリビューションサービスとしても拡張しようと考えています。

Cloud RunとCloud Storageの接続方法は、バケットをストレージボリュームとしてマウントする方式にしました。

cloud.google.com

これは開発時点でPreview版の機能だったのですが、下記の理由で採用しました。

  • チャレンジングな技術採用: 新しい技術を採用することで、将来的な機能拡張や最適化の際に知見を活かせる可能性があり、また開発者の技術力向上とモチベーション向上につながると考えた。
  • 開発効率の向上: ローカル開発環境とクラウド環境で同じファイルシステムインターフェースを使用できるので、開発時の認知負荷を下げれると考えた。

堅苦しく書きましたが、要するに「負荷もそこまで高くなさそう」そして「おもしろそう!」ということです。

新しい技術はリスクも伴います。なので代替案や運用体制を考えてから採用に進みました。代替案は「従来のCloud Storage APIを使用する方法」で、これは社内ですでに実績のあるものです。今後、ファイルアップロード/ディストリビューションサービスへの拡張を検討する際は、Cloud Storage FUSEの特性や制限を考慮し、必要に応じてCloud Storage APIの直接利用も含めた設計を行う予定です。

Cloud Storage FUSEには以下のような制限事項があります。ですが、今回開発するカスタムブロック機能ではパッと懸念が思い浮かばず「代替案もあるしこれ以上深く考えなくても大丈夫だろう」とスピード感を優先しました。

  • 同時書き込みの制御やファイルロックがサポートされていない
  • メタデータの同期が完全ではない
  • 小さなファイルの頻繁な読み書きでパフォーマンスが低下する可能性がある

これからも新しい技術と実用性のバランスを取りながら、知的好奇心を刺激しつつ、より良いプロダクトを開発して参ります。

初回のリリースでは、エディタ機能は提供したもののプレビュー機能は提供していません。「コンテンツを表示する前に一度目視で確認したい!」というニーズには、コンテンツの出しわけ機能(表示先の制限機能)で対応します。確認用のユーザーへのみコンテンツを表示させることでプレビュー機能を代替しました。

これは大きな例でしたが、このように提供できる価値を損ねずに開発工数を節約する工夫は継続していきたいです。(もちろん、後のリリースでユーザーニーズに応じて提供することは念頭に置いてます!)

具体的な実装内容

カスタムブロック機能を実現するためにiframeとpostMessage API使いました。そこに焦点を当てて実装内容を紹介します。コンテンツをデータベースへ保存する/取得するバックエンドAPIの部分は省略します。

コミュニティサイト側(一般ユーザー画面)の実装

コミュニティサイト側へiframe要素を追加します。CommuneのフロントエンドはNext.js/Reactで作っており、このようなReactコンポーネントを用意しました。postMessage APIを使用する際は、必ずoriginをチェックしましょう。信頼できるソースからのメッセージだけを処理して潜在的な攻撃を防ぐためです。

export const CustomBlockIFrame: React.FC<Props> = ({ <カスタムブロック情報の取得に必要なパラメータたち> }) => {
  const customBlock = useCustomBlock(<カスタムブロック情報の取得に必要なパラメータたち>)
  const iframeRef = useRef<HTMLIFrameElement | null>(null)

  useEffect(() => {
    const handler = (event: MessageEvent) => {
      // イベントの発生源がiframe内のコンテンツであることをoriginで確認
      // URI標準に従ってoriginは小文字に正規化されるため、比較前に小文字に変換
      const isEventPostedInThisIframe = customBlock && customBlock.url.toLowerCase().includes(event.origin)
      if (!isEventPostedInThisIframe) return

      const isEventPosted = event.data.type === 'setHeight' && typeof event.data.value === 'number'
      if (isEventPosted && iframeRef.current) {
        iframeRef.current.height = event.data.value
      }
    }

    window.addEventListener('message', handler)
    return () => window.removeEventListener('message', handler)
  }, [customBlock])

  if (!customBlock) return null

  return (
    <iframe
      ref={iframeRef}
      src={customBlock.url}
      scrolling="no"
      className="custom-block-iframe"
    />
  )
}

このReactコンポーネントの仕事は、データベース上に保存されたカスタムブロックコンテンツをiframeに読み込むようにsrc属性の値を設定し、コンテンツの高さを送信するイベントをリッスンしてiframe要素の高さを調整することです。

カスタムブロックコンテンツ側の実装

iframeの中に埋め込むHTML側でpostMessage APIを実行します。HTMLは自由に作れるのですが、少し制限を設けました。

  • iframeの横幅は固定(親要素と同じ幅になる)
  • iframe内はスクロール不可

例えばバナー画像のような静的コンテンツを埋め込む場合、コンテンツ読み込み時やリサイズ時に1度だけ、iframe要素の高さ情報を親サイト(コミュニティサイト)側へ伝える必要があります。下記のようなスクリプトを埋め込みます。

...省略
<body>
    <img src="path/to/banner-image.jpg" alt="Banner">

    <script>
    function updateHeight() {
        const height = document.documentElement.scrollHeight;
        window.parent.postMessage({ type: 'setHeight', value: height }, '<親サイト(コミュニティサイト)のドメイン>');
    }

    window.onload = updateHeight;
    window.onresize = updateHeight;
    </script>
</body>
...省略

また、何らかのイベント(クリックやアニメーション等)でコンテンツの高さが変わるような動的コンテンツを埋め込む場合、イベントを実行するたび高さ情報を親サイト(コミュニティサイト)側へ伝える必要があります。

例えば、アコーディオンメニューを考えると、コンテンツ読み込み時やリサイズ時に加え、アコーディオンの状態が変更されるたびに高さを更新する必要があるかもしれません。その場合、このようなスクリプトを埋め込みます。

...省略
<body>
    <button class="accordion">Section 1</button>
    <div class="panel">
        <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
    </div>

    <button class="accordion">Section 2</button>
    <div class="panel">
        <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
    </div>

    <script>
    function updateHeight() {
        const height = document.documentElement.scrollHeight;
        window.parent.postMessage({ type: 'setHeight', value: height }, '<親サイト(コミュニティサイト)のドメイン>'); // 高さ送信!
    }

    window.onload = updateHeight;
    window.onresize = updateHeight;

    const acc = document.getElementsByClassName("accordion");
    for (let i = 0; i < acc.length; i++) {
        acc[i].addEventListener("click", function() {
            this.classList.toggle("active");
            const panel = this.nextElementSibling;
            if (panel.style.maxHeight) {
                panel.style.maxHeight = null;
            } else {
                panel.style.maxHeight = panel.scrollHeight + "px";
            }
            setTimeout(updateHeight, 200); // トランジションが完了してから高さ送信!
        });
    }
    </script>
</body>
...省略
コミュニティサイト側(管理画面)でコンテンツを入力

コンテンツのHTMLは、コミュニティサイトの管理画面側で設定します。管理画面上に簡易的なエディタ機能を実装することで対応しました。詳細は割愛させていただきます。

開発当時の組織構成

少し脇道にそれますが、当時どんな方々がカスタムブロック機能を開発したか紹介します。

Communeの開発チームはアジャイル手法を採用していて、小さくリリースを重ねることで社内外問わず素早くフィードバックを集めることを通して効果的な開発を目指しています。

私のチームは、下の組織構成図で言うとWebチーム1です。当時はフルタイムメンバーが4人(チームリーダーの私、テックリード、開発メンバー2人)、パートタイマーが2人(業務委託のシニアメンバーと学生インターン)の計6人でした。開発の中の実装作業は、主に、私と開発メンバー2人、シニアメンバーの4人で進めました。

※テックリードはチーム横断で複雑な課題に向き合っており、学生インターンは単一の機能に捉われずさまざまな機能改修を担当しているため、彼らへカスタムブロック機能の開発を渡すのは控えました。とはいえ、私は毎週のようにテックリードに相談をしていたんですけどね…笑

カスタムブロック開発時のCommune開発組織の構成図

当時の開発チームは度重なる組織変更の影響を強めに受けたチームだったので、プロジェクトの初動がとっても不安でした。ですが、実装が始まる前にできるだけ早くDB設計やAPI設計を論理的に進めて懸念点を洗い出し、それをシニアメンバーの知見を借りて潰しておくことで停滞せずに済んだと思っています。

おおよそ2024年の4月から5月の2ヶ月で、初回のリリースを終えました。

カスタムブロック機能を内製化することで、お客様や運用担当者の手間を減らしたりCommuneのネイティブアプリ版でもコンテンツを表示できるようになりました。(これを活かして新規のお客様を獲得できると嬉しい…営業のみなさんよろしくお願いします!)

さらなるプロダクトの成長に向けて

カスタムブロックを開発しながら、私たちは新たな課題や可能性に気づきました。今後もプロダクトを進化させるために、熱い気持ちを胸に取り組んでいきます。

次のステップとして、もっともっと簡単にコミュニティサイトを彩るコンテンツを作り出せるようにし、コミュニティ運営者たちのアイデア具現化を支えたいです。例えばですが、具体的にこんな機能があったらいいなあ、と。

  1. Communeデザインシステムの導入: Communeのコミュニティサイトと調和するUI/UXを提供することで、ユーザーがより直感的にコンテンツを作成できる環境を整える。これによって一貫したデザインになりやすくなるので、ユーザーは本質的な部分(コミュニティのコンセプトに合うコンテンツは何か?どんな形式か?などでしょうか)にさらに頭を使えるようになると見込む。
  2. AI駆動のコンテンツ生成サポート: デザインシステムを学習したAIを活用し、コンテンツ生成をサポートする機能を導入する。これにより、ユーザーは素早く質の高いコンテンツを掲載できるようになると見込む。

また、今回カスタムブロックのために開発した(HTML/CSS/JSといった)静的コンテンツの保存・配信システムはさまざまな用途に活用できると思っています。そう思って一部汎用的な作りにしています(という観点をシニアメンバーにいただきました!笑)。今後の機能開発でも再利用しつつ、いろんなところで使っても実用に耐えうるパフォーマンスを維持していきたいです。

弊社のProduct Roadmapの中で、カスタムブロック機能は今後も重要な位置を占めていくはずです。具体的な内容はここでは控えますが、ユーザーニーズに応じて機能拡張・改善を続けていく予定です。

プロジェクトからの学び

このプロジェクトを進める中で、技術面だけでなく、プロジェクト管理や情報連携など学ぶことがたくさんありました。

  • 情報を一元管理することの大切さ:DesignDocの作成と定例会議の設置は、特にチーム外メンバーやパートタイムメンバーとの連携を効率化するものでした。まだうまく連携方法が確立できてない状況であればなおさら、まずはこの資料見ればOK、このミーディング参加すればOK、という決め事で動き出しをスムーズにできました。「今日○○さんがいないから動けない」がなかったのが良かったです。
  • シニアメンバーの知恵を有効活用すること:ただ答えをもらいにいくのではなく、自分の考えや意見を伝えながら相談することで、課題の捉え方や長期的な視点・システム全体を俯瞰した視点で設計を考えることの大切さを認識しました。例えば、カスタムブロックには表示対象のユーザーを絞れる機能を付けたのですが、これは既存のユーザーグルーピングの仕組みを利用しました。その際、既存機能の仕様や目的をもう一度おさらいしつつ、カスタムブロックで使い回して副作用が起きないかどうかを考えました。
  • 自分自身や社内メンバーからのフィードバックがモチベーションに響くこと:社内ユーザーの声を直接聞くことで、開発者の当事者意識と製品改善へのモチベーションが向上しました。実際に自分たちで触ってあれこれ言ったり、社内のチャットツールにアンテナを広げて生の声を覗くことで「まずい、このままじゃ使いづらい!」と自分ごととして捉えることができました。それによってやる気がアップしたり、時間の許す範囲でエンジニア自ら改善提案してリリースすることにつながりました。
  • 組織の枠を超えて融け合いが生まれたこと:(予想外だったのですが)お客様からお礼の言葉をいただいたり、社内のエンジニアメンバーとビジネスサイドメンバーの交流が生まれることもありました。これはすごく嬉しかったです。おかげで、技術だけでなくビジネス面の理解も深まるきっかけになり、また今後何かビジネス面で困ったときに相談する心のハードルが下がりました(逆も然りなはず!笑)。
  • 手を動かして学習することの大切さ:(これは個人的な反省なのですが…)クラウドインフラを活用するプロジェクトだったにも関わらず知識が身についた実感がありません。自ら手を動かす機会、頭に汗をかく機会をもう少し作れれば良かった…。専門家に頼ることで効率よくプロジェクトを進められた反面、自分たちの技術的な成長の機会を逃してしまったと感じます。

カスタムブロック機能の開発は私たちにとって大きな学びにつながりました。技術として使ったのはiframeとpostMessage APIだけなのですが、価値を届ける開発ができたこと、そしてさまざまな学びがあったことは貴重な経験でした。

ついつい技術を探索したくなるのですが、まずは誰かの困りごとを解決しないと、と自分に言い聞かせるきっかけになりました。

おわりに

今回は、私たちがどうやって機能開発をしているか、どんな課題を解決しようとしているかの一例を紹介しました。今後も、お客様や社内の皆のフィードバックを積極的に取り入れながら、より使いやすく、より便利な機能を作り続けます。Communeを通じて、魅力的で個性豊かなコミュニティサイトがたくさん生まれることを心から楽しみにしています!

Communeでは、私たちと一緒に働く仲間を募集しています!少しでもコミューンの開発組織や職場環境に興味をお持ちの方、ぜひカジュアル面談でお話ししましょう。

docs.google.com

また、具体的な応募意思がない場合でも、将来的にCommuneでのキャリアを検討している方や、選択肢の一つとしてお考えいただける方は、「キャリア登録」にご登録ください。

docs.google.com