👋

Todoリストで全Todo数をカウンティングしてIDにする機能にPub/Subが利用できないか検証してみた

2023/06/14に公開2

概要

  • 現在Firestoreでカスタマーサポートでタスクを管理するオペレーションシステムを構築している今回はカスタマーサポートのタスク管理ツールで使うタスクの判別するタスクIDを、Firestoreで自動設定されるドキュメントIDではなく日付と作られた順番によって変わるカウンティングIDをCloud Pub/Subを使って作成できないかを調査した
  • Firestoreは分散ストレージの都合上ドキュメントIDという大小文字を含めたIDが振り分けられ(例:hdiahuiHDGUjdej)これをわかりやすいIDに変え、業務遂行の際のやり取りをスムーズにすることが目的

カウンティングIDについて

項目名 コード体系 サンプル 説明
タスクID YYMMDDZ99 230208001 先頭6桁はタスクの発行日時で構成する
  • 考案されたタスクIDはその日のタスク数に応じて作られた順に下3桁が変更するものであり、タスク作成の前にタスク枚数をカウントする必要があった為、幾つかの前提と課題を元に実現できる方法があるか調査を行なった
  • その際の解決にあたりCloud Pub/Subが有用である可能性が上がった

前提

  • Firebase/Firestoreを使用
  • ①同時にタスクが作成されるとブッキングするので、発生しないようにする必要がある
  • ②1日に作られるタスク数は100〜300個と仮定し、タスク作成の度に全タスクの読み取りが入るのを防ぐ必要がある(費用を抑えたい)

FirestoreのCOUNTとトランザクションについて

  • ①の課題に関してFirestoreでtransactionを使うことで解決を図ろうとした結果として楽観ロックが働き同時に作成してもブロックがかかり、正確な数が取得できたのでブッキングは起こらないようになった正し後述するCOUNTと複合させることができず全ドキュメント(全タスク)を読み取る必要があった(参考記事:Firestoreのtransactionの使いどころと使い方)
  • ②の課題に関してFirestoreでは集約クエリが存在し、countを使うこうとでドキュメントのカウントを安く用意に可能にした(参考記事:[Firestoreのtransactionの使いどころと使い方]
    • ところが課題の①と②を同時に使うことができないことがわかったので、外部のツール(Cloud FunctionsやCloud Pub/Sub)の力を使おうと考えた

課題②について補足

タスクを作る際のイベントは基本この4つ、ただし処理順序が正しくない場合不整合を起こすのでこれを考慮する必要がある

  • 同時作成について

    • まずタスク数を先にカウントした場合、本来作られなければならないIDはYYMMDD004でなければならない為先にタスクをカウントした場合+1の処理が必要になる

    • が、そもそもとしてこれを同時に走らせた場合、上記の図のように同じタスクIDが二つ作られることになる

    • これはタスク数カウントが後にしタスクIDを更新するにせよ同じ

  • トランザクションのロックが使えない為、正しくIDを振るには同時にタスク作成の押された場合に順序をつけ、処理をスタックさせなければならない

Cloud Pub/Sub

  • 処理をスタックさせる為に非同期処理をさせずにスタックさせる手法として案に上がったのがCloud Pub/Subである
    結論としてできなかったので注意すること

Cloud Pub/Subについて

  • GCPで使えるメッセージサービス、メッセージを送るパブリッシャーがトピックと呼ばれる場所にメッセージを送り、トピックに入ったメッセージはサブスクリプションという場所に運ばれ、最後にサブスクライバーがそれを確認するややこしいけど詳しく解説し図にしてくれている素晴らしい記事があったのでそちらを参考にした

【図解付き】Cloud Pub/Subに概要や使い方についてわかりやすく解説
3. Pub/Subの構成

  • このPub/Subトリガーにはメッセージの順序指定ができるメッセージの中に順序指定キーが存在し、それを入力することで順序が指定できる

順序指定キー

  • 順序指定キーは例えば1,2といったような数をふればその通りになるといったものではなく、言わばキーはキューのようなものになる
  • 同じ順序指定キー(例えばhoge1)を持つメッセージは2つ同時に送られるとタイムスタンプ(早い準)に順序どおりにメッセージが送られる
    これとCloud FunctionsのCloud Pub/Subトリガーを使ってスタックを実現しようとした

Cloud Pub/Subの作り方 ハマった所

Cloud Pub/Subの作り方は下記の記事を参考にした

gcloudでやる場合

Cloud Pub/Subを触ってみた

コンソールでやる場合(公式ドキュメント)

Google Cloud コンソールを使用して Pub/Sub にメッセージをパブリッシュし、受信する

  • パブリッシャーとしてトピックにメッセージを送る際にVue.jsとパッケージ(@google-cloud/pubsub)を使ってフロントからメッセージを送ろうとしたが送れなかった
    • コンセプト的・セキュリティ上フロントから送ることは想定していない可能性があった
    • 結果的にメッセージをを送るCloud Funcitonsを作成した

Cloud Pub/Subにメッセージを送るCloud Functions

index.js
const {PubSub} = require('@google-cloud/pubsub');

// Instantiates a client
const pubsub = new PubSub();

/**
 * Publishes a message to a Cloud Pub/Sub Topic.
 *
 * @example
 * gcloud functions call publish --data '{"topic":"[YOUR_TOPIC_NAME]","message":"Hello, world!"}'
 *
 *   - Replace `[YOUR_TOPIC_NAME]` with your Cloud Pub/Sub topic name.
 *
 * @param {object} req Cloud Function request context.
 * @param {object} req.body The request body.
 * @param {string} req.body.topic Topic name on which to publish.
 * @param {string} req.body.message Message to publish.
 * @param {object} res Cloud Function response context.
 */
exports.publish = async (req, res) => {
  if (!req.body.topic || !req.body.message) {
    res
      .status(400)
      .send(
        'Missing parameter(s); include "topic" and "message" properties in your request.'
      );
    return;
  }

  // References an existing topic
  const topic = pubsub.topic(req.body.topic);

  const messageObject = JSON.stringify({
    data: {
      message: req.body.message,
    },
    attributes: {
      a: "a"
    }
  });

  const messageBuffer = Buffer.from(messageObject);

  // Publishes a message
  try {
    topic.publishMessage(messageBuffer);
    res.status(200).send('Message published.');
  } catch (err) {
    console.error(err);
    res.status(500).send(err);
    return Promise.reject(err);
  }
};

package.json
{
  "name": "nodejs-docs-samples-functions-pubsub-publish",
  "version": "0.0.1",
  "private": true,
  "license": "Apache-2.0",
  "author": "Google Inc.",
  "repository": {
    "type": "git",
    "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git"
  },
  "engines": {
    "node": ">=12.0.0"
  },
  "scripts": {
    "test": "mocha test/*.test.js --timeout=20000 --exit"
  },
  "dependencies": {
    "@google-cloud/pubsub": "^3.0.0"
  },
  "devDependencies": {
    "@google-cloud/functions-framework": "^3.0.0",
    "gaxios": "^5.0.0",
    "mocha": "^10.0.0",
    "sinon": "^15.0.0",
    "wait-port": "^1.0.4"
  }
}

参考記事: [Pub/Sub メッセージのパブリッシュ](https://cloud.google.com/functions/docs/samples/functions-Cloud Pub/Sub-publish?hl=ja)

注意点

上記公式ドキュメントの参考資料の通りに実装しても下記エラーによりうまくいかないことが判明している
Exception from a finished function: TypeError: If data is undefined, at least one attribute must be present.

いくつか資料を調べStackOverFlowで同様の事象があることを確認した結果、公式ドキュメントがカーブボールを投げているとのこと

参考資料
https://stackoverflow.com/questions/71094499/if-data-is-undefined-at-least-one-attribute-must-be-present-Cloud Pub/Sub

stackoverflowによると下記のdataを

{
    "data": "analytics",
    "attributes": {
      "a": a
    }
}

{data: data} の形で渡す必要があり、ドキュメントの下記部分を
topic.publishMessage(messageBuffer);
から
topic.publishMessage({data: messageBuffer});
に書き換える必要がある

また上記のCloud Funcitonsを起動させるサービスアカウントに「Pub/Sub 管理者」ロールを付与しないと起動で権限エラーが出てしまうのでこれも付けておく必要がある

Cloud Pub/SubトリガーのCloud Functionsを作って試してみる

  • Cloud FunctionsにはCloud Pub/Subのメッセージの送信・受信をトリガーとして発火できるものがある
  • Cloud Pub/Subメッセージを送信するCloud Functions①を発火させ、次にCloud Pub/Subをトリガーとしてタスク作成のCloud Functions②を起動させる
  • これにより①で送るメッセージをスタックさせ、②を順番に起動させるという思想を考えた

結論

  • 結論としてが実際にはメッセージ自体はキューで順序指定はできるが、Cloud FunctionsのCloud Pub/Subトリガーはメッセージの発信自体で発火するため、結局同時にメッセージを送信して発火させた場合Cloud Functionsが非同期で起動してしまう為実装できなかった
  • それとCloud Functions内でタスクを作成する処理を行ってしまうとCloud Functionsが止まった際の障害ポイントが増えてしまうこともあったため、今回の要件にCloud Pub/Subは採用にいたらなかった

感想

  • ドキュメントが間違えているという初めての経験を得た
  • 要件に満たなかったが他のGCPサービスとの連携が容易でCloud Functionsと簡単に連携できたことが知れた(権限周りについてはいつも引っかかってるので詳しくなっていきたい)
CBcloud Tech Blog

Discussion

kontikunkontikun

わかった上でかもしれませんが今回の作成したidはホットスポットが生じるのでクエリーの仕方にもよりますがやめておいた方がいいかなと。

業務遂行のスムーズさのためとありますがidに意味を持たせるのは危険な設計かなと。意味のあるものはフィールドに持たせた方が後々困らないと思います。

https://firebase.google.com/docs/firestore/best-practices?hl=ja#high_read_write_and_delete_rates_to_a_narrow_document_range

連番部分が正確にカウントされることにどういう理由がありますか?特になければトランザクションかけてインクリメントを使うのが定番かなと思いました。

https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja#passing_information_out_of_transactions

原田遼原田遼

コメントありがとうございます🙂
仰る通り分散型データベースに対して特定のドキュメントIDを付けてしまうことでホットスポットが生じてしまうことも考慮しました

結果としてカウンター用のコレクションを用意しそこに日別にドキュメントを追加し使う時はトランザクションでインクリメントする方法になりました これをドキュメントのフィールドに持たせています
結果的な実装方法を載せておらず失礼しました🙇🏻

上記も合わせ追記させていだだき、カウンターを使ったやり方は別途記事を作成予定とします