Open17

Cloud Functions for Firebase

じゃーにゃりすとじゃーにゃりすと

index.ts

グローバルオプションの設定

関数ごとにリージョンを設定するのは大変なので、ここで一括で設定できる
デフォルトのタイムアウトは60秒
デフォルトのメモリ設定は256MB

setGlobalOptions({
  region: 'asia-northeast1',
  timeoutSeconds: 540,
  memory: '2GiB',
});

タイムゾーンの設定

関数を日本時刻で実行できるようになる

process.env.TZ = 'Asia/Tokyo';
じゃーにゃりすとじゃーにゃりすと

utilsディレクトリ

複数の場所で使用される関数を格納するために使われる

extractMatchingAnalytics.tsがutilsディレクトリの下にある理由

extractMatchingAnalyticsは特定の役割に特化しているわけではなく幅広く使える関数であるため

じゃーにゃりすとじゃーにゃりすと

extractMatchingAnalytics.ts

UTC(協定世界時)からJST(日本標準時)へ

9時間 × 60分 × 60秒 × 1000ミリ秒

const jstDate = new Date(now.getTime() + 9 * 60 * 60 * 1000);

月を2桁表示させる

1月→0,12月→11なので1を加える
頭に0をつけてsliceで後ろから2文字を取ると1桁の場合にだけ十の位に0がつく

const month = ('0' + (jstDate.getUTCMonth() + 1)).slice(-2);
じゃーにゃりすとじゃーにゃりすと

Intl.DateTimeFormatOptions

日付と時刻のフォーマット方法を指定できる

const options: Intl.DateTimeFormatOptions = {
  year: 'numeric',   // 年を数値で表示する
  month: '2-digit',  // 月を2桁で表示する
  day: '2-digit',    // 日を2桁で表示する
  timeZone: 'Asia/Tokyo'  // タイムゾーンを日本時間にする
};
じゃーにゃりすとじゃーにゃりすと

db.bulkWriter()

Firebase Firestoreの大量の書き込みを効率的に行うための機能
それぞれの書き込み操作に対してエラーハンドリングをカスタマイズすることもできる
失敗した書き込み操作の再試行もできる

const bulkWriter = db.bulkWriter();

// エラーハンドリングの設定
bulkWriter.onWriteError((error) => {
  console.error('Error writing document:', error);
  return error.failedAttempts < 5;  // 失敗回数が5回未満の場合、再試行する
});

// ドキュメントに対する書き込み
const docRef = db.collection('articles').doc('article-id');
bulkWriter.set(docRef, { title: 'New Title' });

// 書き込みの終了
await bulkWriter.close();
  • bulkWriter.set(): 書き込みを行う。ドキュメントが存在しない場合は新しいドキュメントを作成
  • bulkWriter.update(): 既存のドキュメントを更新
  • bulkWriter.delete(): ドキュメントを削除
  • bulkWriter.flush(): 保留中のすべての書き込みを実行
  • bulkWriter.close(): すべての操作を完了してバルクライターを閉じる
じゃーにゃりすとじゃーにゃりすと

Date.toISOString()

日付と時刻をISO8601形式の文字列に変換
ISO8601形式はYYYY-MM-DDTHH:mm:ss.sssZと表す

date.toISOString().slice(0, 7).replace('-', '');

date.toISOString()YYYY-MM-DDTHH:mm:ss.sssZ
slice(0, 7)YYYY-MM
replace('-', '')YYYYMM

じゃーにゃりすとじゃーにゃりすと

Firebase Firestoreのデータベースから特定のドキュメントを検索

コレクションとドキュメントへの参照

db.collection('articles').doc(doc.id).collection('dateData')

コレクションを参照

db.collection('articles'))はarticlesというコレクションを参照

ドキュメントを参照

.doc(doc.id)はarticlesコレクションの中のドキュメントを参照
doc.idは各ドキュメントのID

サブコレクションを参照

.collection('dateData')はdoc.idドキュメントの中にあるサブコレクションdateDataを参照

検索条件を設定

.where(FieldPath.documentId(), '>=', targetMonth)
.where(FieldPath.documentId(), '<', nextMonth)

.where(FieldPath.documentId(), '>=', targetMonth)はdateDataサブコレクション内のドキュメントIDがtargetMonth以上のものをフィルタリング

.where(FieldPath.documentId(), '<', nextMonth)は、dateDataサブコレクション内のドキュメントIDがnextMonthより小さいものをフィルタリング

つまりtargetMonthからnextMonthまでの範囲にあるすべてのドキュメントを取得する

実行

検索条件に基づいてデータベースにリクエストを送る

.get();
じゃーにゃりすとじゃーにゃりすと

Promise.all

複数のPromiseを並行して実行し、すべてが完了するまで待機
すべてのPromiseが解決されると、それぞれのPromiseの結果を含む配列を返す

Promise.allを使うとき

  • 複数の非同期操作を並行して実行したいとき
    複数のAPIリクエストを同時に行い、すべてのリクエストが完了した後で次の処理を実行する
  • 複数のPromiseの結果をまとめて取得したいとき
    すべてのPromiseが解決された後で、その結果を一度に処理する

エラーハンドリング

Promiseのうち1つでも拒否された場合、Promise.allはすぐに拒否し、残りのPromiseの結果を待たない

使用例

Promise.all([
    getSheetData(sheets, dbSheetID, 'access'),
    getSheetData(sheets, dbSheetID, 'cv'),
    getSheetData(sheets, dbSheetID, 'session'),
    getSheetData(sheets, dbSheetID, 'landing'),
    getSheetData(sheets, dbSheetID, 'clicks'),
    getSheetData(sheets, dbSheetID, 'ctr'),
    getSheetData(sheets, dbSheetID, 'impressions'),
    getSheetData(sheets, dbSheetID, 'position'),
  ]),

Promise.allを使うことで複数のgetSheetData関数を並行して実行することができ、処理時間を短縮できる
すべてのPromiseが解決されてから次に進むので、データの欠如を防げる

じゃーにゃりすとじゃーにゃりすと

シートのバッチ更新

Google Sheetsに対して複数の変更を一度にまとめて行う操作のこと

バッチ更新のメリット

  • 処理速度の向上
  • APIの制限回避
じゃーにゃりすとじゃーにゃりすと

スプレッド構文

const monthsWithID = ['ID', ...months.sort()];

先頭に'ID'という文字列を追加し、months.sort()でアルファベット順にソート
...スプレッド構文を使ってソートされた月名を新しい配列に展開

A列に「ID」という項目があるため、先頭に'ID'という文字列を追加を追加している

じゃーにゃりすとじゃーにゃりすと

sheets.spreadsheets.values.getとsheets.spreadsheets.get

const sheet = await sheets.spreadsheets.values.get({
    spreadsheetId,
    range,
  });
  const sheetMetadata = await sheets.spreadsheets.get({
    spreadsheetId,
  });

spreadsheetIdを2回使って何を取得しようとしているのか

sheets.spreadsheets.values.get

特定の範囲(range)のセルデータを取得している
シート内のセルデータ、つまり値のみ

sheets.spreadsheets.get

スプレッドシート全体のメタデータを取得している
各シートのIDや名前など

結論

spreadsheetIdを2回使ってセルデータとシートのメタデータを取得しようとしている

じゃーにゃりすとじゃーにゃりすと

メタデータからシートIDを検索する

const sheetID = sheetMetadata.data.sheets?.find((s) => s?.properties?.title === range);

sheetMetadata.data.sheets

sheetMetadataにはスプレッドシート内にある全てのシートの情報が含まれる
複数のシートを扱っているため、sheetMetadata.data.sheetsはシートのリスト(配列)を表している

[
  { properties: { title: 'Sheet1', sheetId: 12345 } },
  { properties: { title: 'Sheet2', sheetId: 67890 } },
]

オプショナルチェーン ?.

sheetMetadata.data.sheetsがundefinedやnullの場合、エラーを出さずにそのままundefinedを返す

find()

配列の中から条件に一致する最初の要素を返す

s?.properties?.title === range

各シートのproperties.titleが、range(シート名)と一致するかを確認
rangeが"Sheet2"の場合、titleが"Sheet2"のシートを探す

sheetID

.find()によってrangeに一致するシートのオブジェクトが返される

{ properties: { title: 'Sheet2', sheetId: 67890 } }

なぜrangeが必要なのか?titleだけではだめなのか?

まず、sheets.spreadsheets.values.getでtitleを取得することはできないのでrangeを取得する
rangeは'Sheet1!A1:B10'というふうに範囲を指定できるが、'Sheet1'とするとそのシート全体を指定できる
今回はrangeでシート全体を指定しているので、rangeとtitleが一致しfind()で検索してそのシートのIDを取得している

じゃーにゃりすとじゃーにゃりすと

masterFlag

return masterFlag ? sheet.data.values : { sheet: sheet.data.values, sheetID: sheetID.properties?.sheetId };

masterFlagがtrueの場合

sheet.data.valuesを返す
シートのセルの内容のみ

masterFlagがfalseの場合

シートのセルの内容とシートIDを返す

結論

シートIDが不要であればtrue
必要であればfalseにして使い分ける

じゃーにゃりすとじゃーにゃりすと

Jestでテストを行うときにメモリが不足しているときの対処法

node --max-old-space-size=4096 node_modules/.bin/jest

メモリサイズを大きくする

--max-old-space-size=4096はメモリサイズを4GBにする
デフォルトでは約1.5GB

Jestを実行

node_modules/.bin/jestでJestを実行
このあとにファイルを指定するとそのファイルのテストが行える

じゃーにゃりすとじゃーにゃりすと

Jestでテストを行う

現在の時刻の1日前の日付を取得し、指定した形式に変換する関数のテストを行う

関数をimport

テストを行いたい関数をimportする
テストしたい部分を関数として切り出しておくとよい

import { getFormattedDate } from '../../../src/v1/utils/extractMatchingAnalytics';

テストを定義

どの関数のテストを行うか定義する

describe('[Fn] getFormattedDate', () => {

フェイクタイマーを起動させる

テストを行うときは時刻を操作したいのでテストの前にフェイクタイマーを起動させる

beforeEach(() => {
    jest.useFakeTimers();
});

テストが終わったらリアルタイマーに戻す

テストが終了したらリアルタイムのタイマーに戻す

afterEach(() => {
    jest.useRealTimers();
});

複数のテストケースの定義

it.eachで複数のテストケースを一気に行える
currentDateに時刻、expectedに期待されるテスト結果

  it.each`
    currentDate               | expected
    ${'2024-09-19T00:00:00Z'} | ${'20240918'}
    ${'2024-09-19T15:00:00Z'} | ${'20240919'}
    ${'2024-09-01T06:00:00Z'} | ${'20240831'}
    ${'2024-09-01T15:00:00Z'} | ${'20240901'}
    ${'2024-01-01T12:00:00Z'} | ${'20231231'}
    ${'2023-12-31T15:00:00Z'} | ${'20231231'}
  `

テスト行う

時間を設定し、getFormattedDate()関数を呼び出して結果とexpectedが一致するかを確かめる

('Current date is $currentDate', ({ currentDate, expected }) => {
    const date = new Date(currentDate);
    jest.setSystemTime(date);
    const result = getFormattedDate();
    expect(result).toBe(expected);
  });
じゃーにゃりすとじゃーにゃりすと

beforeAllとafterAll

beforeAll

beforeAllはdescribe内の全てのテストが実行される前に一度だけ実行
一度だけ実行すればよい初期設定などで使う

afterAll

afterAllはdescribe内の全てのテストが実行されたあとに一度だけ実行
一度だけ実行すればよい後処理がある場合に使う

beaforeEachとbeforeAllの違い

beaforeEachは各テストケース(itやtest関数)の前に毎回実行される
afterEachも同様に各テストケースの後に毎回実行される

itが2個ある場合(テスト1とテスト2)

beforeAll→beaforeEach→テスト1→afterEach→beaforeEach→テスト2→afterEach→afterAll