Runner in the High

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

React v18におけるCache API周りのコードを読む

React v18では以下のようにCache APIの関数をimportできるのだが、あまりに情報がないので2023年の現時点でのコードを少し読んでみる。

Reactのコミットヒストリを見る限りCache APIは2022年10月ごろにmainへマージされたらしい

github.com

Cache, CacheContext, createCache

まずは cache が呼ばれた際に、そのデータがどこに格納されるのかを知りたい。

少し読んでみると CacheContext という名前で以下のコードが見つかる。

export type Cache = {
  controller: AbortController,
  data: Map<() => mixed, mixed>,
  refCount: number,
};

// ...

export const CacheContext: ReactContext<Cache> = enableCache
  ? {
      $$typeof: REACT_CONTEXT_TYPE,
      // We don't use Consumer/Provider for Cache components. So we'll cheat.
      Consumer: (null: any),
      Provider: (null: any),
      // We'll initialize these at the root.
      _currentValue: (null: any),
      _currentValue2: (null: any),
      _threadCount: 0,
      _defaultValue: (null: any),
      _globalName: (null: any),
    }
  : (null: any);

// ...

// Creates a new empty Cache instance with a ref-count of 0. The caller is responsible
// for retaining the cache once it is in use (retainCache), and releasing the cache
// once it is no longer needed (releaseCache).
export function createCache(): Cache {
  if (!enableCache) {
    return (null: any);
  }
  const cache: Cache = {
    controller: new AbortControllerLocal(),
    data: new Map(),
    refCount: 0,
  };

  return cache;
}

上記のコードのうちCache型のフィールドにある data というMap型の値が、実際にキャッシュを保持していると思われる。

Reactアプリケーションのライフサイクルの中ではルートのFiber生成時に初期化が行われるよう。あとはReactFiberTransitionと呼ばれるモジュールの中でキャッシュプールを生成する際にも使われているようだが、ここは複雑すぎてよく分からなかった。

なお、上記の ReactContext<T> はReact Contextそのものであり、内部的なインメモリキャッシュの実装もReact Contextを用いて行われていることが分かる。

getCacheForType

createCache で生成されたキャッシュはMapをキャッシュデータとして保持するが、実際にほかのモジュールがキャッシュの値を参照するにあたっては getCacheForType という関数を経由する。

// ...

function getCacheForType<T>(resourceType: () => T): T {
  if (!enableCache) {
    throw new Error('Not implemented.');
  }
  const cache: Cache = readContext(CacheContext);
  let cacheForType: T | void = (cache.data.get(resourceType): any);
  if (cacheForType === undefined) {
    cacheForType = resourceType();
    cache.data.set(resourceType, cacheForType);
  }
  return cacheForType;
}

export const DefaultCacheDispatcher: CacheDispatcher = {
  getCacheSignal,
  getCacheForType,
};

この関数は、Reactにおけるキャッシュが必要なあらゆる機能群で使われることが想定されているようである。

例えばReactによるfetch関数の拡張*1にも使われいるし、ユーザーレベルにも unstable_getCacheForType としてexportされている。

cache

さて、とうとう核心部分であるが、以下が cache の関数の実装になる。

getCacheForType のレシーバとなっている dispatcher という変数は、上記で触れた CacheDispatcher インターフェイスを実装したもの。

export function cache<A: Iterable<mixed>, T>(fn: (...A) => T): (...A) => T {
  return function () {
    const dispatcher = ReactCurrentCache.current;
    if (!dispatcher) {
      // If there is no dispatcher, then we treat this as not being cached.
      // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code.
      return fn.apply(null, arguments);
    }
    const fnMap: WeakMap<any, CacheNode<T>> = dispatcher.getCacheForType(
      createCacheRoot,
    );
    const fnNode = fnMap.get(fn);
    let cacheNode: CacheNode<T>;
    if (fnNode === undefined) {
      cacheNode = createCacheNode();
      fnMap.set(fn, cacheNode);
    } else {
      cacheNode = fnNode;
    }
    for (let i = 0, l = arguments.length; i < l; i++) {
      const arg = arguments[i];
      if (
        typeof arg === 'function' ||
        (typeof arg === 'object' && arg !== null)
      ) {
        // Objects go into a WeakMap
        let objectCache = cacheNode.o;
        if (objectCache === null) {
          cacheNode.o = objectCache = new WeakMap();
        }
        const objectNode = objectCache.get(arg);
        if (objectNode === undefined) {
          cacheNode = createCacheNode();
          objectCache.set(arg, cacheNode);
        } else {
          cacheNode = objectNode;
        }
      } else {
        // Primitives go into a regular Map
        let primitiveCache = cacheNode.p;
        if (primitiveCache === null) {
          cacheNode.p = primitiveCache = new Map();
        }
        const primitiveNode = primitiveCache.get(arg);
        if (primitiveNode === undefined) {
          cacheNode = createCacheNode();
          primitiveCache.set(arg, cacheNode);
        } else {
          cacheNode = primitiveNode;
        }
      }
    }
    if (cacheNode.s === TERMINATED) {
      return cacheNode.v;
    }
    if (cacheNode.s === ERRORED) {
      throw cacheNode.v;
    }
    try {
      // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code.
      const result = fn.apply(null, arguments);
      const terminatedNode: TerminatedCacheNode<T> = (cacheNode: any);
      terminatedNode.s = TERMINATED;
      terminatedNode.v = result;
      return result;
    } catch (error) {
      // We store the first error that's thrown and rethrow it.
      const erroredNode: ErroredCacheNode<T> = (cacheNode: any);
      erroredNode.s = ERRORED;
      erroredNode.v = error;
      throw error;
    }
  };
}

細かい説明は面倒なので省くが、ざっくり引数で与えられた関数の arguments をキーにして、再帰的にネストするツリー構造のような形の CacheNode と呼ばれる型の値を生成している。

すでに関数が実行済み (CacheNode.s === TERMINATED) であれば CacheNode.v に保存されている値を返す。

自分はあまりキャッシュアルゴリズムなどの理論に詳しくないため、なぜこのようなキャッシュの構造になっているのかは実装されたPRのdescriptionを読んでもよく分からなかった...

refreshCache

cache 関数の実装は分かったので、次に unstable_useCacheRefresh のほうの実装を見ていく。

いきなり実装から行くが、以下のコードがキャッシュのinvalidationを行うものになる。

unstable_useCacheRefresh はhookであるため、hookを呼び出したコンポーネントの所属するCacheContextが createCache によって初期化される。

function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T): void {
  if (!enableCache) {
    return;
  }
  // TODO: Does Cache work in legacy mode? Should decide and write a test.
  // TODO: Consider warning if the refresh is at discrete priority, or if we
  // otherwise suspect that it wasn't batched properly.
  let provider = fiber.return;
  while (provider !== null) {
    switch (provider.tag) {
      case CacheComponent:
      case HostRoot: {
        // Schedule an update on the cache boundary to trigger a refresh.
        const lane = requestUpdateLane(provider);
        const refreshUpdate = createLegacyQueueUpdate(lane);
        const root = enqueueLegacyQueueUpdate(provider, refreshUpdate, lane);
        if (root !== null) {
          scheduleUpdateOnFiber(root, provider, lane);
          entangleLegacyQueueTransitions(root, provider, lane);
        }

        // TODO: If a refresh never commits, the new cache created here must be
        // released. A simple case is start refreshing a cache boundary, but then
        // unmount that boundary before the refresh completes.
        const seededCache = createCache();
        if (seedKey !== null && seedKey !== undefined && root !== null) {
          if (enableLegacyCache) {
            // Seed the cache with the value passed by the caller. This could be
            // from a server mutation, or it could be a streaming response.
            seededCache.data.set(seedKey, seedValue);
          } else {
            if (__DEV__) {
              console.error(
                'The seed argument is not enabled outside experimental channels.',
              );
            }
          }
        }

        const payload = {
          cache: seededCache,
        };
        refreshUpdate.payload = payload;
        return;
      }
    }
    provider = provider.return;
  }
  // TODO: Warn if unmounted?
}

ReactCacheのテストコードを読むとこのあたりの挙動はわかりやすい。

おそらく Suspense がひとつのCache boundaryという扱いだと思われる。

  test('refresh a cache boundary', async () => {
    let refresh;
    function App() {
      refresh = useCacheRefresh();
      return <AsyncText showVersion={true} text="A" />;
    }

    // Mount initial data
    const root = ReactNoop.createRoot();
    await act(() => {
      root.render(
        <Suspense fallback={<Text text="Loading..." />}>
          <App />
        </Suspense>,
      );
    });
    assertLog(['Cache miss! [A]', 'Loading...']);
    expect(root).toMatchRenderedOutput('Loading...');

    await act(() => {
      resolveMostRecentTextCache('A');
    });
    assertLog(['A [v1]']);
    expect(root).toMatchRenderedOutput('A [v1]');

    // Refresh for new data.
    await act(() => {
      startTransition(() => refresh());
    });
    assertLog(['Cache miss! [A]', 'Loading...']);
    expect(root).toMatchRenderedOutput('A [v1]');

    await act(() => {
      resolveMostRecentTextCache('A');
    });
    // Note that the version has updated
    if (getCacheSignal) {
      assertLog(['A [v2]', 'Cache cleanup: A [v1]']);
    } else {
      assertLog(['A [v2]']);
    }
    expect(root).toMatchRenderedOutput('A [v2]');

    await act(() => {
      root.render('Bye');
    });
    expect(root).toMatchRenderedOutput('Bye');
  });

こういうのはやはりテストコードを読むといい。

今回の学び

  • 近い将来Reactに fetch をCache APIでラップした実装が入る。Canaryなら今すぐ使える。
    • これが入ればfetchに関してはUse APIと組み合わせてすぐにSuspenseなコンポーネントに使うことができるのですごく嬉しい。
  • CacheのinvalidationはCache boundaryやSuspenseなどの単位で行われる。
    • Next.jsのServer-sideで用意されているような、任意のCacheキーを指定して選択的にboundaryを横断してinvalidationするようなことは現状できなさそう。
  • 今回読んだCache APIにまつわる詳しいscrapboxの記事があった → Built-in Suspense Cache APIをどう使うか - tosuke
    • TypeScriptで使うにあたっては @types/react で定義されていないものがちらほらあるが、Reactのコードでexportを見るとunstable なprefixでimportはできるっぽい。

*1:現時点で最新のv18.2.0には含まれていない

Reactの`use`とmoizeを組み合わせるといい感じ

最速攻略記事によると、Reactのuseはキャッシュと組み合わせる必要があらしい。

というわけでmoizeを使ってみたらいい感じだった。

"use client";
import { Suspense, use, useState } from "react";
import moize from "moize";

export default function Home() {
  return (
    <main>
      <h1>Suspense</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <Waiter />
      </Suspense>
    </main>
  );
}

const waiting: () => Promise<string> = moize(
  () =>
    new Promise((resolve) =>
      setTimeout(() => {
        resolve("hello world");
      }, 500)
    )
);

const Waiter = () => {
  const [counter, updateCounter] = useState(0);
  const value = use(waiting());

  return (
    <>
      <button onClick={() => updateCounter((c) => c + 1)}>increment</button>
      <div>Count: {counter}</div>
      <div>Waited: {value}</div>
    </>
  );
};

動作確認

state更新の際にはuseに渡されているPromiseは評価されないので、レンダリングが部分的になっている。

waiting関数の評価は初回のみ。

キャッシュあり

ちなみにmoizeでキャッシュしないと、state更新のタイミングでuseに渡されたwaiting関数も毎回評価されるので再レンダリングされてしまう

キャッシュなし

しかし、moizeをただ単に使うだけだと、キャッシュの管理やinvalidationなどの機構が不十分なため実用性には欠ける。

そういう意味では巷にあるSuspenseサポート系のライブラリを使う方がよい。

追記

2023年8月時点ではReactのmainブランチにCache APIでラップされたfetchが実装されているため、将来的にはclient-side fetchでもmoizeなどのキャッシュライブラリなしでUse APIを楽に使える可能性がある。

izumisy.work

Discord誕生以前のストーリー

open.spotify.com

Podcastで聴いて面白かったので、Wikipediaに書かれていない話だけ備忘録的にメモ。

インキュベータ時代

UCバークレーのルームメイトの親戚がインキュベータを始めるため、起業したい若者を探していた。その話に乗っかりシリコンバレーで起業。最初期はマッチングアプリをやったりFlashマルチプレイヤーのunoを作ったりした。

同時期にスティーブ・ジョブズがAppStoreを発表。それを見て「これは絶対来る」と確信し、作っていたものを全部畳んでゲームアプリ開発に全振り。2008年頃。今ではよくあるRPG風味のパズルゲーを作ってたくさんのユーザーを獲得した。

マネタイズの方法は考えてなかった。

OpenFaint時代

自分たちがゲーム開発で用いていたリーダーボードやチャットルームなどのソーシャル機能を汎化してプラットフォーム化するアイデアを思いつく。

簡単なウェブサイトを作り「3日あればあなたのゲームアプリにマルチプレイヤー機能を組み込めます」という謳い文句で宣伝したが、実際にはその当時は金も時間もなく、作れるかどうかは一旦考えずに欲しいと思っている人からFBを集めることに集中した。このプラットフォームはOpenFaintという名前でローンチされる。

マネタイジングとして最初はフリーミアムからの従量課金を試みたが、あまりうまく行かず。マネタイズの方法はちゃんと考えていなかった。資金調達に関しても、当時はスマートフォンが登場したばかりであり、スマートフォン・フォーカスのプロダクトで調達するのは難儀していた。

GREEから買収提案を受け、プロダクトグロースの観点からメリットがあると判断し買収をacceptするも、結果的にはあまりうまくいかなかった。プロダクトを作ることに集中したかったが、ビジネスサイドのディレクションを要求されたりなどして、ソリが合わず結局辞めてしまった。*1

GREEはOpenFaintを買収から約1年後の2012年にクローズしている。

Discord時代

Discordを作ったタイミングはチャットサービス群雄割拠な時代で、非常にcompetitorの多い市場だった。例えばSkypeなどが市場を席巻していた。

しかし、その当時はブラウザで動くゲーマー用チャットというのはなかった。なのでブラウザベースにすればインストールする必要がなくなり、コンバージョンの障壁をなくせると予想した。WebRTCを使えばボイスチャットも実装できるし、自分たちならうまくやれるという自信があった。

当時はスマホゲー全盛のタイミングだったが、あえて最初からPCゲーマーに全振り。PCゲーマーはスマホゲーマーよりもプレーの連携などでコミュニケーションを必要とするはずだという仮説があった。

なお、マネタイズの方法はとくに考えていなかった。

*1:この辺りの内情を知っている人によると、厳密には某M社の執行役員GREEにいたころにJasonの才能を見抜けずに追い出してしまったとか...

最近飲んだコーヒーとロースタリー

いろいろ買ってるので備忘録がてら書き残しておく。なお筆者はそんなにコーヒーガチ勢ではない。

成城石井 マイルドブレンド

コスパの神。正直Amazonではなくたまに店頭でセールやっているときにたくさん買ったほうがいい。

ちゃんとしたロースタリーで売っているようなやつにも味劣りしないのですごい。

TOKYO COFFEE デカフェ エチオピア

デカフェで夜もコーヒー飲みたい&水抽出でデカフェにしてるのでいいかなと思い購入。

安いやつだと麦茶みたいな味のデカフェのコーヒーがあったりするが、当然そんなことはなく無難に美味しい。

入谷珈琲豆店

たまたま入谷に行った時に見つけたコーヒー屋。オーナーがいろいろ教えてくれて気さく。

シングルオリジンだけじゃなく季節のブレンドとかもオンラインで売っていて良さがある。

入谷ブレンド

iriyacoffee.shopselect.net

定番商品らしい。ノリで買ってみたが結構よかった。

デカフェ メキシコ 中煎り

iriyacoffee.shopselect.net

こちらのdecafも水抽出のもの。

味は忘れてしまったが、デカフェと言われなければ気づかないレベルの味。

珠屋小林珈琲

tamaya.coffee

宮内庁御用達のロースタリーらしいので試しに買ってみた。

オンラインでは取り扱っていないようだが、豊洲のららぽの近くにあるスーパーで豆を購入できる。

奮発してブルマンを購入したが、たしかに値段相応のあっさりしたおいしさがある。

ブロワ珈琲焙煎所

www.blowercoffee.com

前に房総半島へドライブしたときに見つけた。だいぶ辺鄙なところにあるにも関わらずそこそこ混んでいるときがある。

たしかグアマテラかなにかを買った記憶がある。なお、ブラウニーがかなりおいしい。

プラットフォーム・エンジニアリングと過去の経験

Publickeyで公開されていたPlatform Engineering Meetup #1のまとめがとてもよかった。

www.publickey1.jp

スライド中でも書かれているように、共通のプラットフォームを作るのがめちゃくちゃ難しいというのは同意。

自分が今年の1月ごろに書いた記事とも若干関連があると思っていて、やはりいい開発組織はプラットフォームという形をうまく扱っているし、その組織構造の結果としてマイクロサービスがアーキテクチャに現れる。

izumisy.work

詳細はボカすが、かつて僕が働いていた会社でも当時流行っていたチーム・トポロジーの輪読会を経て、プロダクト開発組織の中でいわゆるプラットフォーム・チームというものが組成されたことがある。これは、当時分割されていた個々のマイクロサービスに対してオーナーシップを割り当てる形でチームを再組成し、既存のバックエンドにある認知不可を低減させようという取り組みを包含していた。いわゆる逆コンウェイの法則の適用にあたるもの。

そのプロダクトでは早期からマイクロサービスがチームとは関係ない軸*1で分割されていて、バックエンドの開発者は機能開発の際に複数の(ある程度複雑な)マイクロサービスを横断的に扱うことが要求されていた。このようなマイクロサービスとチームの不整合性が、開発時の認知的負荷を増やし品質の低下やバグを引き起こしているのではないかという説が議論に上がっており、その解決策としてオーナーシップを個別のチームにアサインしてみようという結論になった。そして、そのチームのことを「プラットフォーム・チーム」と呼んだ。

「プラットフォーム・チーム」はただ単にマイクロサービスごとの機能開発を担うだけではなく、名前の通り生産性向上のためのリファクタリングや改善の取り組みも同時に行なっていたのだが、チームに対して複数のゴールが存在することで優先度の問題が影響を及ぼした。マイクロサービスにおける機能開発とプラットフォームという立場で生産性向上の取り組みを兼務しているため、優先度の競合によりバックエンドがなかなか完成せずフロントエンドの開発を前に進められない状況が以前よりも多く起きるようになったのを覚えている。そもそものマイクロサービス分割軸の都合上、チーム間でのコミュニケーションコストの増加を避けることもできなかった。

当時の僕はチームの組成や結果に対しては妥当なものだと思っていたが、今改めて振り返ってみればこの組織における「プラットフォーム・チーム」は全くプラットフォームとは言えなく、どちらかと言えばバックエンド開発のためのチームでしかない。

反省と言っていいのか分からないが、正しくPlatform Engineeringの文脈でものを考えるならば、また別でマイクロサービスに対して生産性向上を担う抽象度の高いフレームワークや共通の基盤を専門で行うチームのことを「プラットフォーム・チーム」と呼称するべきだったと言える。通常の機能開発とプラットフォームとしての開発をひとつのチームで共存させるべきではない。

あるいは、プラットフォームという名を冠することがそもそも間違いだったのかもしれない。

*1:どちらかといえば技術的な都合によるものだった

転職活動でいろんな会社のマイクロサービスと組織を見聞きして思ったこと

転職活動でいろんな会社のエンジニアの人と話して思ったことをマイクロサービスの観点で備忘録がてらメモしておく。

よくあるマイクロサービスの分割軸として、業務機能、ユースケース(動詞)、リソース(名詞)あたりが一般的だが、これらどれもがドメインを構成する要素であるため、マイクロサービスに分割してしまうと結果的にソフトウェアの形がビジネスルールの変化を制限してしまうケースの方が多い気がしていた。実際、規模の小さい開発組織でマイクロサービスやってみました〜からのツラミはそういうのが多いイメージで、よくある「マイクロサービスやったけど逆に開発遅くなった」みたいなとこは上で挙げたような粒度の切り方をしている印象がある。

この件に関して、去年末の転職活動のタイミングでいろいろな人に話聞いてみた結果、実際にマイクロサービスでうまくやっていそうな組織は、上で書いたようなドメインを構成する要素でマイクロサービスをやるのではなく、プロダクト開発のための内製プラットフォームを作るような粒度としてのマイクロサービスを取り扱ってそうな印象を持った。

あとは、そもそもアプリケーションを分離しているパターンもそこそこあったが、これはマイクロサービスと言っていいのか謎なので一旦無視する。

内製プラットフォームとは

雑にいうならばドメイン固有ではない範囲を分離してマイクロサービスにしているイメージ。

これは、いわゆるDDDでいうところの汎用サブドメインに相当するものになる。例えばアプリケーションで使っているサードパーティSDKクラウドリソースがあるとして、それを社内で独自のチームを組成して作らせるような雰囲気。メガベンチャーだとそういうチームが大抵ある気がしていて、例えばLINEのVerdaとかはプラットフォーム超えてクラウドなのですごい。

内製プラットフォームを作るようなチームの存在を維持するためには、それ相応のリソースが必要であり、そこに投資できるだけのプロダクト価値と顧客のニーズも必要。なんなら開発組織の規模に比例すると思う。プロダクトがうまくいっていて、開発に投資できる組織はやはり規模が大きいし、だからこそ内製にアドバンテージが出る。

逆に、無駄に早いフェーズでこういうチームやマイクロサービスを組成して抽象度を間違えると、開発組織の中で無駄にコミュニケーションが必要になったり単なる受発注をするだけのチームがいくつも生まれて地獄絵図になる。マイクロサービスの切り方もおかしくなる。

個人的な解釈

プロダクトとしての独自性を作るために業務機能、ユースケース、リソースが変わることは頻繁にあるので、この変化に技術的な都合で対応できないのは本末転倒。正直なところ、プロダクトが持つ機能の独自性で競合とバトルしている時点でそのプロダクトはジリ貧だと思うが、大抵のプロダクトはそうならざるを得ないのでドメインが定まらないのは仕方ないし、エンジニアリングの面でも対応するしかない。この状況ではドメインは常に不安定なのでマイクロサービスとして分割するには適さない。おそらく、業界のプラットフォーマーと言えるくらいのレベルにならないと安定することはない気がしている。

マイクロサービス・アーキテクチャ1版でもドメインへの理解が低い場合にはマイクロサービスは適さない」と書かれていたが、そもそもドメインが"理解できた"と言えるようなエウレカ的瞬間はプロダクト開発においては形を変えつつ小刻みに連続して現れるのであって、ゴールテープのような"究極の理解"のタイミングがあるわけではない。マイクロサービスであることが連続的に発生するドメイン理解と変化のスピードを遅くする原因になっているのならば、それはむしろマイクロサービスがプロダクト成長の邪魔をしている状態と言っていい。そのような事態を避けて、明確に切り離せそうな単位でマイクロサービスの分割をするならドメインかそれ以外か」くらいの粒度になるわけで、そうなればマイクロサービスとして切り出せる選択肢は汎用サブドメイン以外ない。

みんながみんな内製プラットフォーム的なものを作るチームを持つべきかというと、それは当然なんか違う気がしていて、高度な抽象化をするためにはそれ相応のメリットを感じるだけの規模と価値を持つプロダクトとビジネスの存在が前提になる。あと一般に汎用サブドメインといえば認証や通知*1とかがよく出てくるが、そういうのはサードパーティのプラットフォームを使うだけで十分なことも多い。

結局、ギリギリまで外部のサードパーティ・プラットフォームを使い続けて、とうとうそれがプロダクトのニーズ似合わなくなってきた... という時に初めて、ようやく自前でマイクロサービスとして内製するというレベルでよいのではないか。そして、ある程度うまくやっていそうなところはそういうやり方をしていそうな印象を持った。

PS: この記事書いてて思ったが、過去に書いた以下の記事とかなり内容が近かった。

izumisy.work

*1:なお、通知をマイクロサービスとして抽象的に切り出そうとすると意外に難易度が高いという話がある 通知マイクロサービスはアリ?ナシ? - Speaker Deck

2022年を振り返る

とうとう今年も終わりますね

生活

2021年の5月ごろにノリと勢いで土地を買って家を建て始めていたが、今年の2月にようやく完成。今年は入居して新居を楽しんだ。

家と言えば、そもそも家を建てていた2021-2022年の時点でウッドショックと半導体不足がフィーチャーされていて、それだけでも家を建てるという観点ではそこそこ痛手だったが、そこに輪をかけてインパクトを与えるいまの米国経済の状況を見ると、改めて未来ってどうなるか分からんな... という感想。

とりあえず今のインフレをリセットするために2023年春まで米国では今の景気状況が続くという話が多いが、いずれにしても未来のことは予想できないので今できる最善の選択をする以外ないと思う。

仕事

Uniposを11月で退職してTailorにJOINした www.tailor.tech

「日本からYコンビネータに採択されている」「創業者の経歴が強い*1」「プラットフォームプロダクト」とこれからの未来しか感じられないスタートアップだったので、ここに次の自分の人生をbetする以外の選択肢は無かった。基本的に過去や現在には興味がなくて、未来に可能性があるかどうかしか考えてない。

やっている内容をざっくりかいつまむと、TailorはTailorプラットフォームという「プロダクトを作るためのプロダクト」を提供していて、現在自分はその機能拡充をフィードバックするためのPoCをやっている。ソフトウェア・エンジニアとしての面白さはやはり「プラットフォームであること」に起因するところがあり、通常のソフトウェア開発よりも1段抽象度の高い思考を求められていて楽しい。

なんとなくではあるが、OSSなライブラリだったりミドルウェア開発っぽいことをしてみたいという人には合っているような予感がする。とはいえもちろん抽象度が高いぶん仕様も複雑なので、とりあえずどうにかできる胆力は求められると思うが。

とりあえず気になった方は弊社採用ページからどうぞ。Twitter DMも歓迎です。

まとめ

Uniposのときもそうだが「俺が育ててる、俺と仲間たちで育ててる」と言いたいと思えるようなプロダクトに自分の人生を費やしたほうがいい。

1日8時間やってもまだ足りないと思える仕事じゃなければやる意味がない。

www.youtube.com

*1:これがなぜ重要かについてはベンチャーキャピタル全史を読むと早い

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

前職でフロントエンドチームのスペシャリストとして開発プロセスを考えるポジションにいた。そのときに試してみて良かったなと思えたのはAutoApproveという仕組みで、これは任意のPRにAutoApproveというラベルが付くとGithub Actions経由でPRがGithub BotにApproveされコードレビューなしでマージできるというもの。

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

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

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

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

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

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

余談

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

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

壊れたら気軽にロールバックできるようなリリースプロセスを作り、E2Eほどではないにしてもスモークレベルの自動化テストを用意して、コアの機能以外は壊れても後から余裕をもって修正をリリースできるようにすればいい。もちろんプロダクトやビジネスの性質にもよるが、それでもフロントエンドのほうがやっぱりアグレッシブになれるような気がしている。

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