Runner in the High

技術のことをかくこころみ

コードレビューとオーナーシップ、できる限りコードレビューをしない

前職でフロントエンドチームのスペシャリストとして開発プロセスを考えるポジションにいた。そのときに試してみて良かったなと思えたのはAutoApproveという仕組み。

AutoApproveはGithub Actionsで作られたワークフローで、任意のPRにAutoApproveというラベルが付くと自動でPRがApproveされコードレビューなしでマージできる。

コードレビューでは、プロダクトの規模が大きくなっていくにつれ認知的・時間的なコストが嵩む。レビュワーも広汎なドメインや設計レベルの知識が求められたり、人が増えると「これレビューする意味ある?」みたいなPRもレビュワーにバンバン飛んでくる。そうなると、全体的にさらっと見てApproveしたりする。これはもちろんレビューをされる側もそうで、コードレビューという仕組みがあるとなんとなくレビューをゲートキーピング的に捉えてしまいがちなところがある。

なんのためにコードレビューをするか、という意思統一が抜本的に図れていればいいが、それであっても「レビューが通ってるからOKでしょ」みたいな、ある意味自分のコードのオーナーシップを軽く見がちな傾向がコードレビューに対する向き合い方次第では醸成されやすい。これは 「その変更で何かが起きたらひとりで責任を取れ」という話ではなく、ひとりのソフトウェア・エンジニアとしてある程度のレベルがあるなら自律して品質に責任を持てる状態になっていることを期待できるでしょ、という話。

実際、主観的ではあるがAutoApproveの仕組みがあったことで、ある程度本質的にレビューが必要なものだけが厳選されて飛んでくるようになった感覚があった。たとえばtypo修正だったり、内部的になツールだったり、ビューや設計のちょっとした変更であればみんなさっさと入れてしまうし、あとから共有とともにその良し悪しを議論するほうが圧倒的にスピード感があった。

特に前職のフロントエンドはElmで実装されていたこともあり、言語的な特徴*1から仮に大きな変更と言ってもそのバリエーションには限界があることがかなり予測できていた。このあたりは、言語やフレームワークの自由度に大きく依存する。

若干エクストリームな思想ではあるが、そもそもエンジニア採用の時点でレビューが頻繁に必要になるエンジニアを入れないほうがいいというか、前提としてコードレビューがなくても信頼できてガンガンコミットするのを許せるような人材を集めたほうが、プロダクト開発の生産性は高いんじゃないかという気持ちもある。とくにベンチャーやスタートアップならなおさらそうで、組織の最高速度を上げられるなら無限に上げたほうが良いはずだと思っている。

あと、やっぱり人間は責任を背負わないと成長できない。

余談

とはいえ、冷静に考えるとフロントエンドという領域だからできたみたいな話もある気がする。

言い方には気を付けたいが、バックエンドに比べてフロントエンドのほうが(セキュリティは別として)インシデントのインパクトが小さい傾向はある。たとえばバックエンドで不整合なデータを生み出すようなコードが混入すると、そこそこ重めの復旧作業が発生することが考えられるが、フロントエンドでそういった尾を引くインシデントを起こすリスクは少ないのではないか。もちろんプロダクトやビジネスの性質にもよるが、それでもフロントエンドのほうがアグレッシブになれるような気がしている。

*1:言語仕様が小さくてできることが少ない、フレームワークと言語が統合されている、型システムでテクいことができない、とかそういう特徴を言っている。

クラウドバンクを3年間続けた結果

2020年ごろにこんな記事を書いていた。

izumisy.work

しばらくの間ずっとほったらかして運用し続けていた。

2022年10月現在では結果的に累計で60万ちょっとくらいの利益が累計で出たらしい。

2022年10月ごろの状況

いまは少しづつ投資信託のほうに配分を移しているのだが、基本的に300-400万円くらいを定常的にクラウドバンクの運用に回していたので、それくらいの額で結果的に60万レベルのリターンがあったと考えるとそんなに悪い投資ではなかったかな、という印象。

しかし年間の利回りだけで考えると、正直クラウドバンクよりも投資信託のほうが断然いいものが多いので、それでもクラウドバンクで運用をし続けようという理由が自分のなかでは特になくなってしまった。とはいえ、一部のソーシャルレンディングで印象の悪いものがちらほらいたものの、結果的にクラウドバンクは一番信用できるソーシャルレンディングのプラットフォームだったかな、とは思う。

ブラウザ自動化のツールとその周辺知識に関する備忘録

業務でE2Eテストの導入を進めており、ブラウザ自動化のためのツールに関して調べる必要があったので備忘録的に書き残しておく。

自分のブラウザ自動化周りの知識といえばはるか昔に大学生のころインターンSeleniumを用いたテストの自動化をやったくらいで止まっており、その頃の記憶からブラウザの自動化はあまり信頼のおけない挙動ばっかりする... というイメージがあった。

とはいえ、現在は当時よりも様々な選択肢があるらしいので、少しづつ過去の歴史に触れつつキャッチアップしてみる。

Selenium Remote Control (Selenium 1)

おそらく自分が大学生のときに触ったのはこのRCの世代だと思われる。

www.selenium.dev

Selenium 1はブラウザ上での動作を実行するSelenium Coreとそれに対して操作処理を送信するRemote Controlから構成され、RCサーバがHTTPリクエストを用いてCoreに命令を送信し、Coreがそれを解釈しブラウザ上でJSを実行する形でブラウザの操作を行う。

Selenium WebDriver (Selenium 2)

それまでSelenium RCとして実現されていたリモート・コントロールプロトコルを抽象化/標準化したものがW3C WebDriverであり、それを用いたのがこのSelenium WebDriver (Selenium 2)である。

www.selenium.dev

基本的なメカニズムとしてはSelenium 1とほぼ変わらず、HTTPリクエストによってWebDriverプロトコルを実装したブラウザのドライバの操作が行われる。よって、1と2の違いはWebDriverという通信プロトコルを使うようになったくらいだと思われる。

WebDriverに関しては以下の記事が参考になった。ちょっと古いが、情報は多い。 vividcode.hatenablog.com

CypressとWebDriver

E2Eテスト界隈でそこそこ話題になったCypressはテスト実行においてWebDriverを使っていないため、テストの不安定性(flakiness)が低いとされている。

これは、Cypressのテストがブラウザ上で実行されるひとつのアプリケーションとして動作するため。

medium.com

そもそもブラウザ自動化においてWebDriverとは抽象化されたブラウザとの通信プロトコルでしかない。

WebDriverを使っている限りブラウザとの通信には必ずHTTP通信が必要であり、そのオーバーヘッドがない分Cypressのテスト実行は安定する。もちろん、安定性と引き換えにブラウザ毎のWebDriver実装は使えないため、自前でテストの実行エンジンを実装するしかないわけだが、そこはとにかく実装で頑張っているんだろう*1と思われる。Cypressと同様のアーキテクチャを採用しているのはTestCafeというE2Eツールだが、こちらはIEもサポートしていて結構頑張っているっぽい。

なお、CypressはWebDriverとは異なりひとつのJavaScriptアプリケーションとして動くため、それ相応の制限を受ける。たとえばタブの操作やiframeに対する操作などがそれにあたり、これはCypressのアーキテクチャに由来する制限である。

Chrome Devtools Protocol (CDP)とChromeDriver

chromedriver.chromium.org

ChromeDriverというのはその名の通りChromeのためのWebDriver実装にあたるが、内部的にはWebDriverのプロトコルで送られてきた操作をChrome Devtools Protocol (CDP)へ変換することでWebDriverを実装している。

Chrome Devtools Protocolとはその名の通りChromiumChromeなどBlinkベースのブラウザを外部から操作するためのAPIであり、WebDriverと異なりWebSocketで通信を行う。WebDriverと比べて取得できるデータや操作の種類が多くWebSocketで双方向的に通信ができるため、WebDriverよりも複雑な自動化操作を行えるようなった。詳しくはCDPのAPIドキュメントを見るといろいろ分かる。 chromedevtools.github.io

PuppeteerとPlaywright

ここ最近はブラウザ自動化といえばPuppeteerだ! みたいな記事を見かけることが多い気がするが、その理由はPuppeteerが内部的にWebDriverを使わずCDPを用いて直接ブラウザを操作するからだと思われる。ChromeDriverは テストツール --(WebDriver)--> ChromeDriver --(CDP)--> Chrome という経路で操作を行うため諸々のオーバーヘッドが大きかったが、これがCDPだけになればHTTP通信分のオーバーヘッドがなくなり処理が安定化する。つまりWebDriverよりCDPをメインで操作する自動化ツールのほうがいい、ということになる。

PuppeteerとPlaywrightの違いは調べてみたがよくわからなかった。どこで見たか忘れたがPuppeteerの開発者がPlaywrightのほうに移動しているので、これからはPlaywrightのほうがいいぞみたいな記事がRedditにあった気がする。

WebDriver BiDi (BiDirectional)とSelenium 4

WebDriverのように標準化されたプロトコルでありながらもCDPのように自動化だけじゃなくブラウザの情報とかなんかいろいろ取れるようにしたいよね、というポストWebDriverのポジションとしてWebDriver BiDi (BiDirectional)とかいうやつが標準化されようとしているらしい。

developer.chrome.com

まだDraftらしいがSelenium 4は先んじてこれに対応しようとしている。たぶんPlaywrightとかCypressも将来的にはCDPじゃなくてWebDriver BiDiを使うようになるんだろうな。分からんけど。

zenn.dev

私見

結論、テスト自動化のツールに何を求めるかによって選び方が変わると思った。

以下の図の分類がめちゃくちゃ分かりやすい。 Browser Automation Tools Protocols - Webdriver vs CDP

基本的にはWebDriverは標準化されているだけでflakyテスト多くてつらいのでCDPを採用しているやつを選ぶべきで、ある程度制限があってもいいならCypressやTestCafeあたりを選ぶのが吉、という感じになるのかな。

上の記事によるとCypressに関してはV7からCDPサポートが入ったらしいので、もしかすると自分が先に書いているような制限は実はもうないのかもしれない。

Firebase JS SDKのソースコード・リーディング(初期化処理周り)

最近諸事情あり業務でFirebase JS SDKのDatabase実装周りを読むことがあったので、備忘録的にブログ記事にしてみる。

初期化処理の雰囲気

Databaseまで含めると全体像があまりにでかすぎるので、とりあえず初期化処理周りだけを雰囲気でクラス図にしてみた。staticと書いてあるところはクラスではなくただ単にファイルとそこに定義されている関数であることを示している。

実際にDatabaseそのものの詳細な処理(コネクションハンドリングやら内部での状態管理など)はまた別で解説することとして、この図ではSDKの初期化に関連したクラスとメソッドのみを抜き出すことにした。

Firebase JS SDKの実装ではIoCコンテナとDIが設計において積極的に活用されており、独自のDIコンテナを内部実装している。ComponentというパッケージではDIコンテナの実装となるクラスがまとまっており、AppパッケージにはDIコンテナ(ComponentContainer)の初期化からのコンポーネントの登録など実際のコンテナ操作を行うクラスが多く含まれる。このふたつはFirebase JS SDKにおける基盤的なパッケージであると言える。

今回の図では機能のパッケージとしては Database のみを取り上げたが、Database に限らずそのほかのどの機能のパッケージも上記の図で言うComponentを生成する形で実装されている。新しく機能が増えたとしても、そのパッケージの実装をする側はクラス・インスタンスのライフサイクルをどのように管理するか、などの細かい処理を気にする必要がなく十分に抽象化された基盤を利用しつつ個別の機能パッケージの開発に集中できるようにする思想が垣間見える。

パッケージ間の関係性

App パッケージにおいて initializeAppSDKAPIとして公開されている関数であり、そこが実質的な処理のエントリポイントとなる。

この関数ではFirebase JS SDKにおいて中心的なクラスである FirebaseAppImpl と、それが保持するDIコンテナの実装である ComponentContainerインスタンス化を行っている。

export function initializeApp(
  options: FirebaseOptions,
  rawConfig = {}
): FirebaseApp {
  if (typeof rawConfig !== 'object') {
    const name = rawConfig;
    rawConfig = { name };
  }

  const config: Required<FirebaseAppSettings> = {
    name: DEFAULT_ENTRY_NAME,
    automaticDataCollectionEnabled: false,
    ...rawConfig
  };
  const name = config.name;

  //
  // 省略...
  //

  const container = new ComponentContainer(name);
  for (const component of _components.values()) {
    container.addComponent(component);
  }

  const newApp = new FirebaseAppImpl(options, config, container);

  _apps.set(name, newApp);

  return newApp;
}

特筆すべきはこの関数の中で参照されている _apps_components という変数で、この初期化のタイミングでComponentをコンテナに登録していることがわかる。この二つの変数は packages/app/src/internal.ts というファイルで定義されたグローバルなシングルトン・オブジェクトである。

_appsinitializeApp からのみ値が追加されるが _component_registerComponent という関数からも追加される可能性がある。これはDatabaseなど個別の機能パッケージから利用されるコンポーネント登録のための関数である。

/**
 * @internal
 */
export const _apps = new Map<string, FirebaseApp>();

/**
 * Registered components.
 *
 * @internal
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const _components = new Map<string, Component<any>>();

//
// 省略...
//

/**
 *
 * @param component - the component to register
 * @returns whether or not the component is registered successfully
 *
 * @internal
 */
export function _registerComponent<T extends Name>(
  component: Component<T>
): boolean {
  const componentName = component.name;
  if (_components.has(componentName)) {
    logger.debug(
      `There were multiple attempts to register component ${componentName}.`
    );

    return false;
  }

  _components.set(componentName, component);

  // add the component to existing app instances
  for (const app of _apps.values()) {
    _addComponent(app as FirebaseAppImpl, component);
  }

  return true;
}

このようなシングルトン・オブジェクトが必要な理由はFirebase v8で下記のように機能ごとのパッケージの初期化ができるため。

import firebase from "firebase/app";
import "firebase/database";

上記のようにDatabaseパッケージがimportされると packages/database/src/index.ts から呼ばれる形で下記の registerDatabase 関数が実行される。

import {
  _registerComponent,
  registerVersion,
  SDK_VERSION
} from '@firebase/app';
import { Component, ComponentType } from '@firebase/component';

import { name, version } from '../package.json';
import { setSDKVersion } from '../src/core/version';

import { repoManagerDatabaseFromApp } from './api/Database';

export function registerDatabase(variant?: string): void {
  setSDKVersion(SDK_VERSION);
  _registerComponent(
    new Component(
      'database',
      (container, { instanceIdentifier: url }) => {
        const app = container.getProvider('app').getImmediate()!;
        const authProvider = container.getProvider('auth-internal');
        const appCheckProvider = container.getProvider('app-check-internal');
        return repoManagerDatabaseFromApp(
          app,
          authProvider,
          appCheckProvider,
          url
        );
      },
      ComponentType.PUBLIC
    ).setMultipleInstances(true)
  );
  registerVersion(name, version, variant);
  // BUILD_TARGET will be replaced by values like esm5, esm2017, cjs5, etc during the compilation
  registerVersion(name, version, '__BUILD_TARGET__');
}

_registerComponent 関数が呼ばれることにより _components にComponentが追加され initializeApp 関数の呼び出し時にDIコンテナへのComponentの登録が自動で行われる。ここでComponentクラスのコンストラクタ第2引数として渡されている無名関数はインスタンス生成処理のIFにあたる InstanceFactory の実装であり、なんとなくファクトリがいい感じで遅延実行される雰囲気を感じられる。

実際にファクトリとなる関数が呼びされるのは、コンポーネントの取得時(クラス図における _getProvider 関数の呼び出し時)になる。例えばDatabaseパッケージは getDatabase 関数の中でその呼び出しを行う。

 * Returns the instance of the Realtime Database SDK that is associated
 * with the provided {@link @firebase/app#FirebaseApp}. Initializes a new instance with
 * with default settings if no instance exists or if the existing instance uses
 * a custom database URL.
 *
 * @param app - The {@link @firebase/app#FirebaseApp} instance that the returned Realtime
 * Database instance is associated with.
 * @param url - The URL of the Realtime Database instance to connect to. If not
 * provided, the SDK connects to the default instance of the Firebase App.
 * @returns The `Database` instance of the provided app.
 */
export function getDatabase(
  app: FirebaseApp = getApp(),
  url?: string
): Database {
  return _getProvider(app, 'database').getImmediate({
    identifier: url
  }) as Database;
}

_getProvider の実装は FirebaseApp からComponentContainerを経由し該当のProviderを取得する動きになっている

/**
 *
 * @param app - FirebaseApp instance
 * @param name - service name
 *
 * @returns the provider for the service with the matching name
 *
 * @internal
 */
export function _getProvider<T extends Name>(
  app: FirebaseApp,
  name: T
): Provider<T> {
  // ...
  return (app as FirebaseAppImpl).container.getProvider(name);
}

Providerクラスは主にComponentクラス・インスタンスのライフサイクルを管理する責務を担っており getImmediate でその挙動をみることができる。

  /**
   *
   * @param options.identifier A provider can provide mulitple instances of a service
   * if this.component.multipleInstances is true.
   * @param options.optional If optional is false or not provided, the method throws an error when
   * the service is not immediately available.
   * If optional is true, the method returns null if the service is not immediately available.
   */
  getImmediate(options: {
    identifier?: string;
    optional: true;
  }): NameServiceMapping[T] | null;
  getImmediate(options?: {
    identifier?: string;
    optional?: false;
  }): NameServiceMapping[T];
  getImmediate(options?: {
    identifier?: string;
    optional?: boolean;
  }): NameServiceMapping[T] | null {
    // if multipleInstances is not supported, use the default name
    const normalizedIdentifier = this.normalizeInstanceIdentifier(
      options?.identifier
    );
    const optional = options?.optional ?? false;

    if (
      this.isInitialized(normalizedIdentifier) ||
      this.shouldAutoInitialize()
    ) {
      try {
        return this.getOrInitializeService({
          instanceIdentifier: normalizedIdentifier
        });
      } catch (e) {
        if (optional) {
          return null;
        } else {
          throw e;
        }
      }
    } else {
      // In case a component is not initialized and should/can not be auto-initialized at the moment, return null if the optional flag is set, or throw
      if (optional) {
        return null;
      } else {
        throw Error(`Service ${this.name} is not available`);
      }
    }
  }

shouldAutoInitialize はComponentの初期化タイミングを制御するフラグであるらしく、実装によれば LAZY, EAGER, EXPLICIT の3つから選択される。SDKにおけるComponentのデフォルト値もLAZYが指定されているため、基本的にはすべての機能のクラスがこの getImmediate のタイミングでファクトリの呼び出しによって実体化されると考えていいだろう。

実際の初期化処理は getOrInitializeService メソッドの中で行われ、ここでようやくComponentに登録された instanceFactory が実行されるのが分かる。すでにファクトリが実行されている場合には初期化処理をスキップする。

  private getOrInitializeService({
    instanceIdentifier,
    options = {}
  }: {
    instanceIdentifier: string;
    options?: Record<string, unknown>;
  }): NameServiceMapping[T] | null {
    let instance = this.instances.get(instanceIdentifier);
    if (!instance && this.component) {
      instance = this.component.instanceFactory(this.container, {
        instanceIdentifier: normalizeIdentifierForFactory(instanceIdentifier),
        options
      });
      this.instances.set(instanceIdentifier, instance);
      this.instancesOptions.set(instanceIdentifier, options);

      /**
       * Invoke onInit listeners.
       * Note this.component.onInstanceCreated is different, which is used by the component creator,
       * while onInit listeners are registered by consumers of the provider.
       */
      this.invokeOnInitCallbacks(instance, instanceIdentifier);

      /**
       * Order is important
       * onInstanceCreated() should be called after this.instances.set(instanceIdentifier, instance); which
       * makes `isInitialized()` return true.
       */
      if (this.component.onInstanceCreated) {
        try {
          this.component.onInstanceCreated(
            this.container,
            instanceIdentifier,
            instance
          );
        } catch {
          // ignore errors in the onInstanceCreatedCallback
        }
      }
    }

    return instance || null;
  }

なんとなく Componentインスタンスの生成手段や生成タイミングを保持するだけのデータ構造という雰囲気が強く、ファクトリで生成されたインスタンスのライフサイクルを管理するのが Provider であるように見える。

非常に典型的なDIコンテナの実装という感じ。

NameServiceMapping に関して

たとえば上記の getOrInitializeService メソッドだったり InstanceFactoryNameServiceMapping という型を返す実装になっている。

/**
 * Factory to create an instance of type T, given a ComponentContainer.
 * ComponentContainer is the IOC container that provides {@link Provider}
 * for dependencies.
 *
 * NOTE: The container only provides {@link Provider} rather than the actual instances of dependencies.
 * It is useful for lazily loaded dependencies and optional dependencies.
 */
export type InstanceFactory<T extends Name> = (
  container: ComponentContainer,
  options: InstanceFactoryOptions
) => NameServiceMapping[T];

この型は以下のような実装になっている。

/**
 * This interface will be extended by Firebase SDKs to provide service name and service type mapping.
 * It is used as a generic constraint to ensure type safety.
 */
export interface NameServiceMapping {}

export type Name = keyof NameServiceMapping;
export type Service = NameServiceMapping[Name];

上記のコメントに書いてある通りこれは型安全性を担保するためのもので、Databaseの場合にはindex.tsに以下のような形で関連する実装がある。

declare module '@firebase/component' {
  interface NameServiceMapping {
    'database': Database;
  }
}

あとはComponentのコンストラクタを見ればわかりやすい。DatabaseパッケージのComponent登録の処理では namedatabase になるため、該当するインスタンスの型が導きだされることが分かる。

export class Component<T extends Name = Name> {
  // ...

  /**
   *
   * @param name The public service name, e.g. app, auth, firestore, database
   * @param instanceFactory Service factory responsible for creating the public interface
   * @param type whether the service provided by the component is public or private
   */
  constructor(
    readonly name: T,
    readonly instanceFactory: InstanceFactory<T>,
    readonly type: ComponentType
  ) {}

  // ...
}

PlaywrightでGithub ActionのJob Summary用レポータを作るといい感じ

Github ActionにJob Summaryという機能があり、これを使うとActionの結果をmarkdownに対して出力できる。

github.blog

所属しているチームではPlaywrightをテストに使用しているのだが、毎回Actionの実行でエラーがおきたときにStepを開いて見に行く必要があり、まあまあ面倒。第一に見ずらい。

そこで、playwrightが提供しているReporterでJob Summary用の実装を用意すると、こんな感じでテスト結果をいい感じに表示できる。

エラーがあるときは各リトライの結果を表示させるようしている。

import * as fs from 'fs';
import {FullConfig, TestError} from '@playwright/test';
import {FullResult, Reporter, Suite, TestCase, TestResult} from '@playwright/test/reporter';

interface Error {
  title: string;
  error: TestError;
}

export default class GithubSummaryReporter implements Reporter {
  outputFile?: fs.WriteStream;
  errors: Error[];

  onBegin(config: FullConfig, suite: Suite) {
    const summaryFile = process.env.GITHUB_STEP_SUMMARY
    if (summaryFile) {
      this.outputFile = fs.createWriteStream(summaryFile, {flags: 'a'});
    }

    this.errors = [];
    this.writeOut("## Summary\n")
    this.writeOut("Case|Status|Duration")
    this.writeOut("----|------|--------")
  }

  onEnd(result: FullResult) {
    if (this.errors.length > 0) {
      let errorDetails = "\n## Errors\n"
      this.errors.forEach(({title, error}, attempt) => {
        errorDetails += `\n#### ${title} (#${attempt} attempt)`;
        errorDetails += "\n```diff\n";
        errorDetails += error.message.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
        errorDetails += "\n```\n";
      })
      this.writeOut(errorDetails)
    }

    if (this.outputFile) {
      this.outputFile.end();
      this.outputFile.close();
    }
  }

  onTestEnd(test: TestCase, result: TestResult) {
    const title = test.titlePath().filter(v => v !== '').join(' > ');
    const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
    if (result.status !== 'passed') {
      result.errors.forEach(error => {
        this.errors.push({title, error})
      })
    }
    this.writeOut(`${title} (#${result.retry})|${status}|${result.duration}ms`)
  }

  private writeOut(value: string) {
    if (this.outputFile) {
      this.outputFile.write(value + "\n");
    } else {
      console.log(value)
    }
  }
}

細かい話だが、エラーメッセージのsyntaxをdiffにすると差分がいい感じで表示できる。

あとはreplaceでANSIエスケープシーケンスを取り除くところもポイントで、playwrightはレポータに渡してくるエラーメッセージの文字列にカラーコードがつけっぱなしなのでレポータ側で外してやらないといけない。

Firebase Database REST API Streamingの挙動メモ

複雑すぎるのでまとめる

ベースとなるJSONの構造は以下。すべてFirebase Database Emulatorで試している。

posts配下へのフィールドの追加

{
  type: 'put',
  data: '{"path":"/book3","data":"awesome book"}',
  lastEventId: '',
  origin: 'http://localhost:9000'
}

posts配下フィールド(book2)の更新

{
  type: 'put',
  data: '{"path":"/book2","data":"great book (updated)"}',
  lastEventId: '',
  origin: 'http://localhost:9000'
}

posts配下フィールドの削除(book3)

 {
  type: 'put',
  data: '{"path":"/book3","data":null}',
  lastEventId: '',
  origin: 'http://localhost:9000'
}

いろいろ試してみたがpatchが飛んでくるケースが見つけられなかった。

あとputとpatchの違いも謎で、一見すると更新系ならpatchでも良さそうだがそれもputで飛んでくる。もしかして孫フィールドの更新ならどうだ? と思い、それぞれのbookに { name: "nice book" } のようなObjectっぽい構造を持たせて更新を試してみたが、そうすると今度はpathが /book2/name になった状態でputが送信されてくる。

Elmをローカルインストールしているとelm-language-serverのDiagnosticsが動かなくなるバグを修正するPRを投げた

github.com

仕事で開発しているElmアプリケーションとElmLSの相性が悪いのかDiagnosticsが動かないことが多かった。動くときもあるが、動かない時の方が多いレベル。もう諦めてWebpackでのビルド結果を見ながら作業をしていたが、非常に生産が悪かったのでこの気に調査してみることにした。

すると、以下の条件でElmLSのDiagnosticsがうまく動かなくなるバグがあることがわかった。

  • Elmをローカルインストールしている(npm install -D elm とか)
  • main関数を公開しているエントリポイントとなるモジュールに対するテストが存在している

なぜこのような挙動になっているかというと、ElmLSは内部的にDiagnosticsの生成の際elm makeelm-test makeを使い分ける挙動になっているため。

ざっくり言うと、エントリポイントとなるモジュールに対してテストが存在している場合はelm-test makeコンパイルを行い、そうでない場合にはelm makeコンパイルを行う。今回のバグの再現条件である前者の場合elm-testはElmコンパイラのバイナリを常にグローバルインストール前提で探すため、ローカルインストールだと見つけられずDiagnosticsが生成できなくなってしまう。今回の自分の修正では、Elmをローカルインストールしている場合はelm-testに対して--compilerというElmコンパイラのパスを指定させるオプションを渡すことでバグを解決した。

なぜelm makeではなくelm-test makeを使うような挙動が存在しているかについては、推測の域ではあるがelm-test makeのほうがコードベースを網羅的にコンパイルできるからではないかと想像している。Elmコンパイラ単体ではテストコードをコンパイルできない(TestパッケージやExepectパッケージを見つけられない)ため、アプリケーションのソースコードとテストコード両方を包含してDiagnosticsを生成するためにはelmとelm-testの両方を動かさなければいけないが、可能なケースであればelm-testだけを動かすことでDiagnosticsの生成時間を短縮できる。エントリポイントにテストがあるケースではそれに該当するわけだが、ローカルインストールされている状況が考慮されていなかったため、今回のバグが生まれたと考えられる。

abema/go-mp4で複数ストリームが含まれるmdatから音声のサンプルのみを取り出す

去年書いた以下の記事はmp4/m4aといいつつ、実はファイルの中に動画のストリームが入っているケースを考慮できていない。

izumisy.work

その記事を書いた時点では「どうやらストリームが複数ある場合にmdatは数サンプルごとのチャンクの詰め合わせで構成されているっぽい」ということしか分かっていなかった。軽く調べても簡単にわかりそうな感じでもなかったので、とりあえず音声のストリームだけがmdatに含まれる前提で話を進めることにしていたが、実際的なユースケースではmp4には動画と音声がセットで含まれているケースのほうが多い。

というわけで、そのようなケースでどのようにmdatを読み取ればいいのか改めて調べてみたので備忘録的に残しておく。

サンプルとチャンク

日本語の参考資料としては以下のWikiが最も参考になる。

github.com

これ以外にもstsc, stco, stszあたりの関係性は以下のstackoverflowが参考になった。

stackoverflow.com

これらの記事によると、複数ストリームから構成されるmp4ファイルのmdatは、複数のサンプルを持つチャンクと呼ばれる単位をひとつの塊としているらしい。

それぞれのストリームはmdat中での自分のストリームに該当するチャンクのオフセットの値を保持しているため、mdatを前から順番にサンプルサイズで舐めていったとしてもオフセット値が正しくなければ、中途半端なサンプルだったり別のストリームのサンプルを取り出してしまうことになる。従って、複数のストリームが存在するmp4ファイルにおいてはストリームのサンプルサイズだけではなくチャンクのオフセットを知ることが重要になる。オフセットが分からなければサンプルサイズが分かっても意味がない。

abema/go-mp4を使う例

abema/go-mp4は実は複数ストリームが存在しているケースを扱うのに非常に便利で、パッケージから提供されるTrackという構造体を見るだけで大体のことは完結させられる。

type Track struct {
    TrackID   uint32
    Timescale uint32
    Duration  uint64
    Codec     Codec
    Encrypted bool
    EditList  EditList
    Samples   Samples
    Chunks    Chunks
    AVC       *AVCDecConfigInfo
    MP4A      *MP4AInfo
}

ここにあるChunksというのが、まさにmdatにおけるストリームのチャンク情報そのものである。

type Chunks []*Chunk

type Chunk struct {
    DataOffset      uint32 // mdatにおけるchunkのオフセット
    SamplesPerChunk uint32 // chunkに含まれるサンプル数
}

この二つの情報が手に入れば話は早い。

自分は以下のような感じで SamplesChunks からmdat中のサンプルのオフセットとサイズを計算するロジックを書いた。やっていることはシンプルで、チャンクのカウンタとチャンク内のサンプルのカウンタを加算しながら「いまどのチャンクのどのサンプルを読んでいるか?」をイテレーションして返していくだけの実装。

type FrameIterator struct {
    samples           mp4.Samples // track.Samples
    sampleIndex       int         // 0
    chunks            mp4.Chunks  // track.Chunks
    chunkIndex        int         // 0
    chunkSampleIndex  int         // 0
    chunkSampleOffset uint32      // track.Chunks[0].DataOffset
}

type Frame struct {
    Offset uint32
    Size   uint32
}

// イテレーションしてフレームの情報を返すメソッド
func (v *FrameIterator) Next() *Frame {
    if len(v.samples) <= v.sampleIndex || len(v.chunks) <= v.chunkIndex {
        return nil
    }

    offset := v.chunkSampleOffset
    size := v.samples[v.sampleIndex].Size
    currentChunk := v.chunks[v.chunkIndex]

    if currentChunk.SamplesPerChunk <= uint32(v.chunkSampleIndex+1) {
        if len(v.chunks) <= v.chunkIndex+1 {
            return nil
        }
        v.chunkIndex++
        v.chunkSampleIndex = 0
        v.chunkSampleOffset = v.chunks[v.chunkIndex].DataOffset
    } else {
        v.chunkSampleIndex++
        v.chunkSampleOffset += v.samples[v.sampleIndex].Size
    }

    v.sampleIndex++
    return &Frame{
        Offset: offset,
        Size:   size,
    }
}

github.com

ここまで出来たらば FrameIterator へ音声のストリームに該当するTrackの値を与えてやるだけでmdat中の音声サンプルのオフセットとサイズを取得できるので、取得された情報をもとにSectionReaderあたりでファイルをシークしつつバイナリデータを取り出してfdkaacでデコードするなりすればいい。