くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

Redash の出力結果を Chrome 拡張機能で Markdown にコピーできるようにした話

こんにちは、エンジニアの yuma です。

突然ですが、皆さんは Redash を使っていますか?弊社では本番データを参照する際に Redash を使っており、エンジニアはもちろん、エンジニアでない方もデータ分析に活用しています。

redash.io

Redash では豊富なビジュアライゼーションが提供されていますが、データ分析には有用な一方でエンジニア間でデータを共有するには少々不便でした。

そこで、Redash の出力結果を Markdown のテーブルでコピーできるようにする Chrome 拡張機能を作成しました。拡張機能自体を公開することはできませんが、 Chrome 拡張機能の開発や実装時のポイントを共有します。

なお、スクリーンショットを含む本記事で掲載しているデータは、全てデモ用のダミーデータです。

モチベーション

弊社では、原則エンジニアを含めて本番データの参照には Redash を利用しています。本番データの変更は特定のエンジニアのみが実行権限を持ち、どのような変更であっても GitLab 上で変更内容を起票、適切な承認フローを通した上で変更を依頼します。

その際、正しい変更が行われたかどうかを確認するために、変更前のデータと変更後の想定データを記入し、レビューを受ける必要があります。エンジニアは Redash でクエリを実行して変更前のデータを取得しますが、 Redash の出力結果はコピペがしづらく、GitLab へのデータの貼り付けが手間になっていました。

ワークアラウンド的な方法もあるようですが、頻度の高い作業であるため、せっかくならワンボタンでコピペできるようにしたい!と思い、Chrome 拡張機能を作成することにしました。

あまりコピペしやすい形式とはいえない...

構成

簡易的な図ですが、以下のような構成で開発しました。

表示しているページに任意のスクリプトを挿入できる Content scripts でコピーボタンの挿入やボタン押下時のテーブル取得を行い、バックグラウンドで実行される Service worker にメッセージを送信します。

Content scripts では、 Redash のテーブル表示を CSV ライクな文字列配列に変換し、 Service worker へメッセージを送ります。 Markdown 形式への変換は Service worker で実行し、 Content scripts は返却された Markdown 文字列をクリップボードにコピーする、という流れです。

より詳しくみていきましょう。

Content scripts

Content scripts は、表示しているページに任意のスクリプトを挿入できる機能です。挿入されたページの DOM にアクセスできるため、ページ上の要素を取得・操作が可能です。

developer.chrome.com

今回は、 Redash のクエリ実行画面(/queries)にコピー用のボタンを挿入します。コピー用のボタンは React コンポーネントとして実装し、ReactDOM で root.render() することで Redash の画面上に描画しています。以下の「Copy as Markdown」ボタンが、 Chrome 拡張機能で描画されたコンポーネントです。

ただし、 Redash は SPA で構築されており、ページ遷移時にリロードが発生しないため Content scripts が再実行されません。また遷移後のクエリ実行画面でも、ボタン部分を含め頻繁に表示が変化するため、単純にボタンを挿入するコードだけでは DOM の再構築によってボタンが消えてしまいます。

このような DOM の変更を監視・コンポーネントを再描画するために、 MutationObserver を利用しました。

MutationObserver

MutationObserver は、 DOM の変更を監視することができる WebAPI です。特定の DOM の変更をその子孫も含めて監視することができ、その変更をコールバック関数に通知します。

developer.mozilla.org

変更の内容は MutationRecord というクラスインスタンスで通知され、この辺りに少々癖がある印象でしたが、フレームワークなしでも DOM の変更を監視して扱えるのは非常に便利でした。

今回は、クエリ実行画面の下部バー DOM が追加されたタイミングで、コピー用ボタンのコンポーネントを描画しました。クエリ実行画面の遷移や、クエリを実行した際の再描画でもボタンが表示されるようになりました。

  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      // MutationRecord.addedNodes で、追加されたノードを取得できる
      mutation.addedNodes.forEach((node) => {
        if (node.hoge() === "fuga")) {
          // ... 中略 ...
          // div を挿入して React コンポーネントを描画
          const rootDom = document.createElement("div");
          bottomController.insertBefore(
            rootDom,
            bottomController.lastElementChild
          );
          const root = createRoot(rootDom);
          root.render(createElement(CopyAsMarkdown));
        }
      });
    }
  });

Service workers

Service workers は、ページ内で動作するスクリプトとは別にバックグラウンドで実行されるスクリプトです。

各ページから発火されるイベントのハンドラを定義することで様々なイベントを監視できるほか、ページに挿入した Content scripts からのメッセージを受けることもできます。今回はテーブルのデータをペイロードとして受け取り、 Markdown 形式に変換するメッセージハンドラを実装しました。

chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
  if (message.type === "RRC_MARKDOWN_COPY") {
    const csvAsArray = (message as Messages["CopyMarkdown"]).data;
    sendResponse(markdownTable(csvAsArray));
  }
  return;
});

メッセージの送信も以下のようにシンプルな API で実現できます。

const execCopyMessage = (data: Csv, callback: (response: string) => void) => {
  const message = {
    type: "RRC_MARKDOWN_COPY",
    data,
  };
  return chrome.runtime.sendMessage(message, callback);
};

manifest.json

manifest.json は Chrome 拡張機能本体の設定ファイルです。配布時に重要になるような、拡張機能の名称や説明・アイコンなどはあまり気にせず、実装に必要な部分だけ設定しました。

ポイントとなる部分だけを抜粋して解説します。

content_scripts / background

実行したい Content scripts , Service workers のスクリプトを指定します。

Content scripts には、実行したいページの URL を正規表現や独自のパターン表現で設定できます。

  "content_scripts": [
    {
      "js": [
        "content.js"
      ],
      "matches": [
        "https://<Redash サービスのホスト>/queries/*"
      ]
    }
  ],
  "background": {
    "service_worker": "worker.js",
  },

permissions

Chrome 拡張機能向け API や一部のスクリプトのためには、ユーザーからの権限許可が必要です。 permissions に必要な権限を記述することで、 Chrome 拡張機能からユーザーに権限の許可を求めることができます。

今回はクリップボードへの書き込み権限が必要なため、 clipboardWrite を必須の権限に追加しました。

  "permissions": [
    "clipboardWrite"
  ]

補足: CRXJS Vite Plugin について

弊社では TypeScript を開発言語に採用しています。Chrome 拡張機能の開発でも TypeScript を使うことにしました。

「Chrome 拡張 TypeScript」などで検索すると、 CRXJS という Vite プラグインがヒットすると思いますが、今回はこちらの採用は見送りました。*1

crxjs.dev

CRXJS が対応しているのは Vite 2、ベータバージョンでも Vite 3 までのため、最新のバージョンとは大きく離れてしまっています。今回の開発内容で得られる恩恵は manifest.json の型付け程度で重要度も低いことから、CRXJS を使わなくても特段問題にはなりませんでした。

まとめ

今回は、 Chrome 拡張機能を開発する中でのポイントをいくつかご紹介しました。

ストアでの公開・配布を考慮すると、安定度やメンテナンス性などをさらに考える必要がありそうですが、個人用や社内ツールとして簡単に使う上ではそこまでハードルは高くないように思います。皆さんも、身近なツールでかゆいところに手が届かないようなときには、ぜひ自分用の Chrome 拡張機能を作ってみてください!

最後に

みんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!

詳しくは、こちらをご覧ください。

*1:正確にはプロトタイプの段階では CRXJS を採用していましたが、 React コンポーネント化に際して Vite のバージョンが問題となってしまったため、CRXJS を採用しない方針に変更しました。

エンジニアが出店者が集まるイベントに参加してみた

バックエンドエンジニアのあべです。

エンジニアが、サミットと呼ばれる出店者イベントに参加した感想をまとめてみました。

動画も含めて紹介していますので、ぜひご覧いただければと思います。

サミットとは

くらしのマーケットに出店している方々(以下、出店者)が、参加する出店者コミュニティのイベントです。

東京・大阪・名古屋・福岡など全国各地で開催されており、毎回多くの出店者が参加しています。

基本的には、出店者が企画・運営をしており、くらしのマーケットで活躍する出店者の発表やグループワークを通じて、サービスページの作り方や集客方法などお互いに情報共有して教え合う場となっています。

サミットの動画↓

www.youtube.com

サミットに参加した理由

「くらしのマーケットアワード」に参加したことがきっかけです。

「くらしのマーケットアワード」とは、年に一度開催されるくらしのマーケットでお客様からの支持が特に高かった店舗を表彰する式典です。

私は、2021年の「くらしのマーケットアワード」に初めて参加して、アワードの盛り上がりに衝撃を受けました。

くらしのマーケットで活躍する出店者の方々が受賞・表彰されて、涙を流しながら喜ぶ姿、またそれを称え合う姿に、素晴らしいプロダクトを開発できていることに感動しました。

翌年に、サミットが開催されるということで、実際に出店者の方々とお話をしてみたいと思ったのが参加の理由です。

2023年のくらしのマーケットアワードの動画↓

www.youtube.com

くらしのマーケットアワード2023の特集記事↓

https://curama.jp/featured/curama-awards/list/

エンジニアが実際に参加してみて

開発した内容についての率直なフィードバックを受けることができる

「この機能が便利になった」「この機能に助けられています」など意見をもらえました。

自分が開発したサービスが、利用者に価値を与えていることを実感できることはとてもやりがいになります。フィードバックの中には、自分が直接開発に関わっていない機能もあるので、それは社内に持ち帰り、担当エンジニアに伝えたりしています。

また、くらしのマーケット一本で仕事をしている出店者からの期待の声を聞くと、責任感も湧きました。

お話をさせていただいている中で、「こういうことに困っている」「こういう悩みがある」など、出店者が抱える根本的な問題を考えるきっかけにもなっています。

話を聞いて視座を高めることによって、開発するときに利用者の立場を想像することにも役立つと考えており、貴重なコミュニケーションです。

出店者のサービスページの作り込みや研究がスゴイ

出店者が、集客のためにサービスページの研究に日々取り組んでいることに驚きました。

活躍している出店者の方々は、写真や動画の工夫をはじめとして、文章の細部までこだわって作成しています。

普段、開発しているエンジニアでも気づかないようなポイントにフォーカスしていて、こちらも勉強することがたくさんあります。

また、出店者の方は、普段自分たちが利用している店舗管理画面の変化だけではなく、ユーザー(お客さん)が利用する画面での細かな変化に気づいて対策する出店者の方もいて、研究がスゴイと感じています。

出店者の方々のポジティブな変化を知ることができる

去年初めてサミットに参加して、まだくらしのマーケットに登録したばかりだった出店者の方が、1年後にはサミット運営をして口コミ数や売上が何倍にもなっていて、成功を感じることができました。

くらしのマーケットを利用して、努力を続けて成功している出店者の方を見ることができるのもサミットならではだと思います。

サミットで自己紹介をするあべ(緊張気味)

出店者のグループワークに参加しちゃうあべ

最後に

エンジニアは、どうしても開発や技術にフォーカスして、プロダクトから興味が離れてしまいがちです。(すみません、偏見かもしれないです。)

ただ、エンジニアにとって、開発したプロダクトを利用しているユーザーと直接コミュニケーションをとるのは、プロダクトが利用者に与える価値を身近に感じることができます。

手を挙げれば、エンジニアでもくらしのマーケットアワードやサミットに参加できるチャンスがあるのは、良い環境だと思います。 たまには、そういうことに参加してみちゃうエンジニアがいても、良いのではないでしょうか。

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。

ts-pattern という OSS のコードリーディングしてみた

はじめに

こんにちは!バックエンドエンジニアのハラノです。 最近開発をしていて、try catchを使ったエラーハンドリングに少し不満を持つようになりました。
型だけを見ても発生しうる Error がわからないため、エラーハンドリングを行うときに、実際の処理を追う必要がある上、エラーハンドリングが網羅されていなくてもコンパイルが失敗しないからです。

そのため、より適切にエラーハンドリングをする方法がないか探していたところ、Result 型を提供するneverthrowとパターンマッチングを行うts-patternというライブラリがあることを知りました。

組み合わせることで、Result 型で発生するエラーがわかる上に、ts-pattern のパターンマッチングを使うことによって、発生するエラーが網羅されていない場合コンパイルエラーにすることができます。

もともと OSS のコードを読んでみてして新しい学びを得たかったのと、サイズも大きすぎずコードリーディングするのに適していると感じたので、今回はts-patternの読んで、内部構造を調べてみました!

どのようにコードリーディングしたのか、今回得られた知見について、今回共有いたします。

どのようにコードリーディングしたのか

cursorというエディタが前々から気になっており、コードを clone し、コードの処理を追いかけつつ、cursor の chat で質問し、わかったことについて Notion でメモしていました。
AI の回答は正しいとは限らないため、生成された回答について正しそうか?というのを確認しつつ進めました!
精度が高く、また同じようなことをするなら絶対使おうと思えるレベルでした!

以下、一部の回答例です。

cursorの回答_1

cursorの回答_2

今回のサンプルコードについて

以下のコードが内部でどのようにハンドリングされ、hello world が出力されるのかを調べていきます。

このコードはどのような値でも マッチ するP.any を使っており、結果 マッチ してhello world が出力されます。

import { match, P } from "ts-pattern";

type Input = {
  text: string;
};

const input: Input = {
  text: "hello world",
};

const result = match(input)
  .with(P.any, (i) => i.text)
  .exhaustive();

console.log(result);
// hello world

それぞれの関数を簡単に説明すると以下のようになります。

  • match(input) でパターンマッチングのビルダーを作成
  • .with() でパターンを追加、パターンマッチした場合に実行する関数を追加
  • .exhaustive() でパターンの結果を返す

それぞれの処理について具体的に見ていきます!

match()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L31

export function match<const input, output = symbols.unset>(
  value: input
): Match<input, output> {
  return new MatchExpression(value, unmatched) as any;
}

こちらを見てみると match は単純に MatchExpression を new して返しているだけのようです。

MatchExpression の実装は以下になっており、with() の実装についても MatchExpression にあります。

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L46

以下の記述があり、MatchExpression は入力とマッチングの状態を持ち、入力に対してパターンマッチする処理の責務を持つクラスになっています。

/**
 * This class represents a match expression. It follows the
 * builder pattern, we chain methods to add features to the expression
 * until we call `.exhaustive`, `.otherwise` or the unsafe `.run`
 * method to execute it.
 *
 * The types of this class aren't public, the public type definition
 * can be found in src/types/Match.ts.
 */
class MatchExpression<input, output> {
  constructor(private input: input, private state: MatchState<output>) {}

  // 以下省略
}

with()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L49

  with(...args: any[]): MatchExpression<input, output> {
    if (this.state.matched) return this;

    const handler: (selection: unknown, value: input) => output =
      args[args.length - 1];

    const patterns: Pattern<input>[] = [args[0]];
    let predicate: ((value: input) => unknown) | undefined = undefined;

    if (args.length === 3 && typeof args[1] === 'function') {
      // case with guard as second argument
      predicate = args[1];
    } else if (args.length > 2) {
      // case with several patterns
      patterns.push(...args.slice(1, args.length - 1));
    }

    let hasSelections = false;
    let selected: Record<string, unknown> = {};
    const select = (key: string, value: unknown) => {
      hasSelections = true;
      selected[key] = value;
    };

    const matched =
      patterns.some((pattern) => matchPattern(pattern, this.input, select)) &&
      (predicate ? Boolean(predicate(this.input)) : true);

    const selections = hasSelections
      ? symbols.anonymousSelectKey in selected
        ? selected[symbols.anonymousSelectKey]
        : selected
      : this.input;

    const state = matched
      ? {
          matched: true as const,
          value: handler(selections, this.input),
        }
      : unmatched;

    return new MatchExpression(this.input, state);
  }

今回は第 1 引数にP.any ,第 2 引数に マッチ したときに実行される関数を渡した時の流れを説明します。 コードとして表すと以下になります。

match(input).with(P.any, (i) => i.text);
  1. handler に パターンがマッチしたときに実行する関数を格納する
  2. patterns に P.any が代入される
  3. それぞれの pattern について matchPattern()関数が実行され、パターンがマッチ しているかの boolean が格納される
    1. matchPattern 内では Matcher(P.any)の場合 P.any[Symbol.for("@ts-pattern/matcher")]().match(value) で パターンがマッチ しているかを比較しています
  4. マッチしている場は、{ matched: true, value: handler() } として新しい MatchExpression を作成して return する

P.any

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L660

export const any: AnyPattern = chainable(when(isUnknown));

以下の関数を使用しているようです。

  • isUnknown()
  • when()
  • chainable()

isUnknown()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L612

function isUnknown(x: unknown): x is unknown {
  return true;
}

unknown かどうかを判定する関数で、常に true を返す関数のようです。

when()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L526

export function when<input, predicate extends (value: input) => unknown>(
  predicate: predicate
): GuardP<
  input,
  predicate extends (value: any) => value is infer narrowed ? narrowed : never
>;
export function when<input, narrowed extends input, excluded>(
  predicate: (input: input) => input is narrowed
): GuardExcludeP<input, narrowed, excluded>;
export function when<input, predicate extends (value: input) => unknown>(
  predicate: predicate
): GuardP<
  input,
  predicate extends (value: any) => value is infer narrowed ? narrowed : never
> {
  return {
    [matcher]: () => ({
      match: <UnknownInput>(value: UnknownInput | input) => ({
        matched: Boolean(predicate(value as input)),
      }),
    }),
  };
}

Symbol.for("@ts-pattern/matcher") の key に与えられた関数に input を渡してマッチしているかの関数を提供する object を返す関数のようです。

文章だけだと分かりづらいので、それぞれを console に出力して確認します。

このようにP.anyなどの Pattern は、マッチ するかの関数を提供しています。

const matcherSymbol = Symbol.for("@ts-pattern/matcher");
const matcher = (P.any as any)[matcherSymbol]();

console.log(P.any);
/*
{
  [Symbol(@ts-pattern/matcher)]: [Function],
  optional: [Function: optional],
  and: [Function: and],
  or: [Function: or],
  select: [Function: select],
}
*/

console.log(matcher);
/*
{
  match: [Function: match],
}
*/

console.log(matcher.match());
/*
{
  matched: true,
}
*/

chainable()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/patterns.ts#L118

function chainable<pattern extends Matcher<any, any, any, any, any>>(
  pattern: pattern
): Chainable<pattern> {
  return Object.assign(pattern, {
    optional: () => optional(pattern),
    and: (p2: any) => intersection(pattern, p2),
    or: (p2: any) => union(pattern, p2),
    select: (key: any) =>
      key === undefined ? select(pattern) : select(key, pattern),
  }) as Chainable<pattern>;
}

P.any.select()などができるように、select などを元の object に追加しているようです。

exhaustive()

https://github.com/gvergnaud/ts-pattern/blob/bfb3e68011f810a5f9343df4ba10d42e4c89f4fb/src/match.ts#L114

  exhaustive(): output {
    if (this.state.matched) return this.state.value;

    let displayedValue;
    try {
      displayedValue = JSON.stringify(this.input);
    } catch (e) {
      displayedValue = this.input;
    }

    throw new Error(
      `Pattern matching error: no pattern matches value ${displayedValue}`
    );
  }

マッチ している場合は、value を返し、マッチ するパターンがなければ Error を実装するようになっています。

実際は型でのガードによってケースが網羅されていない場合は型エラーになるため、実行時に Error になることはそこまで多くなさそうです。

まとめ

改めて最初のコードを再掲します。

import { match, P } from "ts-pattern";

type Input = {
  text: string;
};

const input: Input = {
  text: "hello world",
};

const result = match(input) // MatchExpressionのインスタンスを生成する
  .with(P.any, (i) => i.text) // 何にでもMatchするP.anyを渡し、matchした場合は(i) => i.textを実行、内部のステートを更新する
  .exhaustive(); // matchしていない場合、Errorをthrowする

console.log(result);
// マッチしているため、 hello world が出力される

内部で何が行われているのかイメージがついたのではないでしょうか?

今回調べてみて、{ text: P.string }P.string.select()をパターンとして渡した場合などについても、学びを深めていきたいと感じました!

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。

Twilio AudioSwitchを使ってAndroid Bluetooth通話を爆速で開発する方法

こんにちは、モバイルアプリエンジニアのリュウです。

最近、ワイヤレスイヤホンなどのBluetoothデバイスを使って通話する対応を行いました。 そこで、Twilio AudioSwitchというライブラリを知って利用しました。

本記事では、AudioSwitchの使い方とその開発で得た知見をご紹介します。

開発の背景

我々「くらしのマーケット」が提供しているユーザー向けアプリと店舗向けアプリには、 インターネットを介したIPネットワークを通じて音声通話をする機能(VoIP)があります。

ユーザーがアプリ内での通話をより便利に利用できるために、以下のようなさらなる進化を遂げたいと考えました。 - Bluetoothデバイスとのシームレスな連携 - Bluetoothデバイスに接続したら、Bluetoothで通話を続ける - Bluetoothデバイスに接続中に、端末のスピーカーなど他のデバイスに切り替えられる

AudioSwitchとは

AudioSwitchは、オーディオデバイスの管理を簡素化するAndroid向けのツールです。

AudioSwitchの特徴

  • オーディオデバイスの管理

    スピーカーフォン、イヤホン、またはヘッドセット間でオーディオ入出力を簡単に切り替えあれる。

  • オーディオデバイスの可用性の変更の検出

    オーディオデバイスの入出力の可用性が変更された際に、この変更を検出し、適切に対応できる。

  • エラーやタイムアウトの処理

    オーディオデバイスの選択中にエラーが発生した場合や、タイムアウトが発生した場合にも、適切に処理できる。

  • Bluetoothデバイスの選択

    Bluetoothデバイスを安全的にハンドリングできる。

AudioSwitchを採用した理由

  • 開発コストの削減

    Android標準のフレームワーク(例えばAudioManager)を使用すると、数百行もの複雑なコードが必要になるが、AudioSwitchはこれらの機能をカプセル化したため、数十行で完結できる。

  • ライブラリの一貫性

    「くらしのマーケットのアプリ」ではTwilio Voice SDKを使用して音声通話機能を開発したため、同じくTwilioが提供しているライブラリを導入することでライブラリの一貫性を保つことができる。

実際に取り組んだこと

さて、サンプルコードとともに解説していきます。

導入

まずは、GradleにAudioSwitchの設定を追加します。

dependencies {
  implementation 'com.twilio:audioswitch:1.1.8'
}

インスタンス化

AudioSwitchを使い始める前に、アプリケーション コンテキスト参照を使用してインスタンス化します。

    // 必要に応じてログの有効化・無効化を行う
    val audioSwitch = AudioSwitch(context, loggingEnabled = true)

アプリのライフサイクル全体で必要なのは、1つのAudioSwitchインスタンスだけです。
依存性の注入(DI)フレームワークを使用してコンポーネントに依存関係を注入するのも良いでしょう。

デバイスの変化をリッスンする

オーディオデバイスの可用性の変更をリアルタイムで検出できるよう、リッスンを開始します。

また、不要なタイミングに停止させます。

fun start() {
    // リッスンの開始
    audioSwitch.start { devices, selectedDevice ->
        // 有効なデバイスや選択したデバイスが変わるたびに実行される
        handleAudioDeviceChanges(devices)
    }
}

fun stop() {
    // リッスンの停止
    audioSwitch.stop()
}

デバイスの変化により選択したデバイスを自動的に切り替える

Bluetooth機器の接続状態・使用状態をもとに、接続や切断などの処理を行います。

// 有効なデバイスのキャッシュ
val cachedAvailableDevices = mutableListOf<AudioDevice>()

// 指定したデバイスを使用する
fun useAudioDevice(device: AudioDevice?) {
    if (audioSwitch.selectedAudioDevice == device) {
        return
    }
    audioSwitch.selectDevice(device)
    // デバイスを選択した後は、そのデバイスのアクティブ化が必須
    audioSwitch.activate()
}

fun findBluetooth(devices: List<AudioDevice>) =
    devices.find { it is AudioDevice.BluetoothHeadset }

fun handleAudioDeviceChanges(devices: List<AudioDevice>) {
    val oldBluetooth = findBluetooth(cachedAvailableDevices)
    val newBluetooth = findBluetooth(devices)

    // 変化したデバイスはBluetoothかどうかを確認
    if (oldBluetooth != newBluetooth) {
        when (newBluetooth) {
            // 接続 -> 切断
            null -> when {
                // 選択中のデバイスをリセットする(端末に戻る)
                audioSwitch.selectedAudioDevice is AudioDevice.BluetoothHeadset -> useAudioDevice(null)
            }

            // 切断 -> 接続
            else -> useAudioDevice(newBluetooth)
        }
    }

    // Bluetoothは変化なし
    cachedAvailableDevices.clear()
    cachedAvailableDevices.addAll(devices)
}

次回の課題

現在、Bluetooth機器のボタンで電話に応答・切電する機能に対応していません。
このため、TwilioとAndroid ConnectionServiceを統合して、この機能を実現したいと考えています。

ConnectionServiceは、Androidの通話管理システムを拡張し、通話の発信、着信、通話中の動作制御などを簡単に実装できるフレームワークです。
Twilioの通話機能と組み合わせることで、より安定した通話体験や追加機能の実装が可能となります。

まとめ

この記事では、Bluetooth通話機能の開発においてTwilio AudioSwitchがどのように活用されるかを解説しました。
今後のプロジェクトでBluetooth通話機能を実装する際は、ぜひ、検討してみてくださいね。


最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!
詳しくは、こちらをご覧ください。

Node.js18を20にアップデートして、jestの実行速度を3倍にした

こんにちは!バックエンドエンジニアのハラノです。
くらしのマーケットのシステムの中には、Node.js(NestJS)を使用したマイクロサービスが存在しており、今回 Node.js のバージョンアップを行いました。
バージョンアップの方針及び、実際にアップデートを行う際に出てきた問題とその対策をご紹介します。

バージョンアップの方針

まずは、Active LTS になった Node.js を 20 に追従し、負債化しないようにすることが目的でした。
また、TypeScript もバージョンが少し古くなっていたため、ミニマムに進めるためにも当初は Node.js と TypeScript のみをバージョンアップしようと考えていました。
しかし、Node.js と TypeScript のバージョンアップを行ったところ、NestJS のバージョンが古く互換性がないようで、コンパイルが通らなくなってしまい NestJS のバージョンアップも必要になってしまいました。
この時点で、当初考えていた規模より大きくなったため、チームメンバーと話し合い、痛みを伴ってでも主に使用しているライブラリのバージョンアップを行うことで負債を残さないようにする、ということを決めました。
そのため、今回は Node.js 自体のバージョンアップに加え、TypeScript、NestJS、TypeORM などの主に使用しているライブラリのバージョンアップも行うことにしました。

バージョンアップの結果

最終的には主に以下をバージョンアップすることにしました。
また、この他にも devDependencies なライブラリ(Prettier, ESLint) も現時点の最新バージョンに更新しています。

ライブラリ名 バージョンアップ前 バージョンアップ後
Node.js v18 v20
NestJS v6 v10
TypeScript v3.9 v5.3
TypeORM v0.2 v0.3
Jest, ts-jest v26 v29, ts-jest は@swc/jest に置き換えました

以下では、バージョンアップなどの対応を行うにあたり発生した問題などを共有いたします。

各種対応において、発生した問題と対応

TypeScript のバージョンアップ

TypeScript のバージョンアップを行ったところ以下のような部分で、コンパイルエラーが発生するようになりました。

try {
    throw new Error("test");
} catch (e) {
    // ここでエラーが発生する
    console.log(e.message);
}

原因は、TypeScript4.4 で追加されたオプションであるuseUnknownInCatchVariablesです。ドキュメント
このオプションは、catch 節での変数の型を unknown にするかどうかを指定するもので、strict を true にしている場合デフォルトで true になります。
e は本来 unknown 型になるのが適切ですが、e が any に推論されてる前提で実装されており、e.messageを logging するなどの処理が大量にあり、今回は全てを変更する必要はないと判断したため、以下のように tsconfig.json で false に設定することで回避しました。

{
+   "useUnknownInCatchVariables": true
}

NestJS のバージョンアップ

@nestjs/common から HttpService, HttpModule が削除された

@nestjs/commonからHttpService,HttpModuleが削除されました。
@nestjs/axiosに移動しており、interface の変更はなかったため 以下のように import を変更することで解決できました。

-import { HttpModule } from '@nestjs/common';
-import { HttpService } from '@nestjs/common';
+import { HttpModule } from '@nestjs/axios';
+import { HttpService } from '@nestjs/axios';

Inject にInject(TestRepository.name)のように、クラス名を渡している部分について、依存関係の解決が行えなくなっていた

依存の解決について、厳密に行うようになっていたようで、@Inject(TestRepository.name)のような記述をしている部分で、依存関係の解決が行えなくなっていました。
以下のように、クラスを渡すように変更することで解決しました。

@Injectable()
export class TestLogic {
-   constructor(@Inject(TestRepository.name) private testRepository: ITestRepository) {}
+   constructor(@Inject(TestRepository) private testRepository: ITestRepository) {}
}

RxJS のtoPromiseが Deprecated になった

NestJS は非同期処理に RxJS を利用していますが、RxJS のtoPromiseが Deprecated になっていました。
現在の使い方であれば、lastValueFromを使って解決できることが、ドキュメントに記載されていたため、以下のようにlastValueFromを使うように変更することで解決しました。

As a replacement to the deprecated toPromise() method, you should use one of the two built in static conversion functions firstValueFrom or lastValueFrom. ※ドキュメントより引用

class TestApiClass {
    @Inject() private httpService!: HttpService;

    async post(payload: any) {
        const url = ''; // 省略
-       return this.httpService.post(url, payload).toPromise();
+       return lastValueFrom(this.httpService.post(url, payload));
    }
}

TypeORM のバージョンアップ

Connection が Deprecated になった

接続方法がConnectionを使う方法から、DataSourceを使う方法に標準が変更されました。
Connectionで設定している内容をDataSourceで設定するように変更することで、対応しました。
また、作成した Connection を使用して Repostiory に Inject するようにしていたため、Repository 全体で DataSource を使うように変更したうえで、型も変更しました。

@Injectable()
export class TestRepository {
-   constructor(@Inject(ProdDBConnection) private connection: Connection) {}
+   constructor(@Inject(ProdDataSource) private dataSource: DataSource) {}
}

ormconfig のサポートが無くなった

TypeORM 0.3 から ormconfig のサポートが無くなりました。

現在は非推奨になっただけなため、影響はありませんが 0.4.0 で削除されるため、対応を行わない場合 cli から呼び出しが行えなくなります。

そのため、前述した DataSource を使用するように変更したうえで、cli から呼び出す際は ormconfig で設定していた情報をオプションとしてつけることで対応しました。

-npm run typeorm migration:create -- -n
+npm run typeorm migration:generate -- -d ${マイグレーションを乗せるdirectory} -n

グレイスフルシャットダウンの処理が正常に完了しない

npm のバージョンアップを行った際に、グレイスフルシャットダウンの処理が正常に完了しない問題が発生しました。

今回対応したマイクロサービスは Http Request の他 Rabbit MQ を subscribe して、非同期処理を行う Worker も動作しており ECS にデプロイされていました。
Worker について、デプロイ時古いタスクを停止するために、 SIGTERM が発行される -> Rabbit MQ から新しい Event を取得しないようにする -> 現在実行している処理がすべて完了したら Rabbit MQ の Connection を閉じてシャットダウンする、という処理を行っていました。

しかし、npm のバージョンアップによって、SIGTERM が発行されても、Node.js 側でそれを受け取ることができず、一定時間後に SIGKILL が発行されて、強制的に終了するようになってしまいました。

Issue を探してみると 10.3.0 で解決したとの記述がありましたが実際に試しても治りませんでした。

npm 経由で呼び出しをしていることで発生していたため、一時的にnodeコマンドで直接呼び出すように変更し、SIGTERM を受け取ることができるようになりました。

Deprecated なコードの利用を削除

ライブラリのバージョンアップをした際に、deprecated なコードの利用が多く残っていることに気づきました。
一つ一つ探して削除するのは大変なため、deprecated なものを使用しているとエラーにしてくれる ESLint 拡張を追加し、呼び出しを探して推奨される対応を行い、進めました。

ユニットテストの実行時間を三分の一にした

ユニットテスト実行の際は ts-jest でトランスパイルしてから実行する形になっています。
しかし、ts-jest はトランスパイルに時間がかかり、最初のテスト実行までに 3~40 秒程度かかっていました。

ライブラリをバージョンアップしてテストを実行してというサイクルを回していたのですが、非常に時間がかかっていたので調べたところ、ユニットテスト高速化のために、@swc/jest などの選択肢があることを知りました。
esbuild-jestを使う選択肢もありましたが、@swc/jestNestJS の公式ドキュメントにも記載されており、@swc/jestを選択しました。
@swc/jestを使用してユニットテストを行ったところ、複数箇所でエラーが発生するようになってしまったので、以下の部分を変更することでテストが正常に動作するようになり、最初のテスト実行までに数秒しかかからなくなり、全体としては三分の一程度で完了するようになりました。

  • TypeORM でリレーションをしているような型についてRelation<Profile>に変更する
    • swc のほうが厳格にチェックしており、Initialize される前に型定義で使用すると、エラーになってしまうためのようです。
  • tsconfig.json の esModuleInterop を true に変更する
    • swc では esModuleInterop を true にしたような動きをしており、tsconfig.json 側を変更しないと行けない状況でした
    • この変更に伴い、CommonJS modules で書かれたものの import 方法が変わりました

移行自体は行えましたが、@swc/jestでは正しくカバレッジを計測できない問題が発生しました。
CI でカバレッジが一定以下だと落ちるようにしており、対応の必要があります。
そのため、カバレッジ計測の場合は現在も ts-jest を使用しています。

まとめ

今回、Node.js のバージョンアップを行い、それに伴うライブラリのバージョンアップを行いました。
コアで使用しているライブラリのバージョンアップを行い、持続的に開発を行うために負債を残さないようにできたと考えています。
また、ユニットテストの実行時間を三分の一にすることができ、開発効率の向上にもつながりました。
一方、当初より規模が大きくなってしまった点は、改善の余地があると考えており、次に活かすためにも学んでいきたいと思いました。

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。