mizdra's blog

ぽよぐらみんぐ

Relay でも msw の handler を型付きで書く

msw を使うと、GraphQL API をモックしてダミーレスポンスを返せます。以下のような handler と、その他少々のセットアップコードを用意するだけで簡単にモックできます。

// src/mocks/handlers.js
import { graphql } from 'msw';

export const handlers = [
  // ブログのトップページのクエリをモックする handler。
  // `graphql.query` の第一引数には、
  // `query <クエリ名> { ... }` の `<クエリ名>` の部分に対応した文字列を書く。
  graphql.query('BlogTopQuery', (req, res, ctx) => {
    return res(
      ctx.data({
        blog: { title: 'My Blog' },
        entries: [
          { id: '1', title: 'My First Entry' },
          { id: '2', title: 'My Second Entry' },
        ],
      }),
    );
  }),
];

graphql.query はクエリの response の型と variable の型の、2 つの型引数を取る API になってます。それぞれの型引数を渡すと、req.variables や handler の返り値に (静的) 型が付き、型に違反する値を返そうとしたらコンパイルエラーにできます。クエリの response/variable の型定義は、grahpql-codegen とその plugin (@graphql-codegen/typescript@graphql-codegen/typescript-operations) を使ってコード生成できます。

// src/mocks/handlers.js
import { BlogTopQuery, BlogTopQueryVariables } from '../gql/types';
import { graphql } from 'msw';

export const handlers = [
  graphql.query<
    BlogTopQuery,
    BlogTopQueryVariables,
  >('BlogTopQuery', (req, res, ctx) => {
    return res(
      ctx.data({
        blog: { title: 'My Blog' },
        entries: [
          { id: '1', title: 'My First Entry' },
          { id: '2', title: 'My Second Entry' },
        ],
      }),
    );
  }),
];
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  generates: {
  'src/gql/types.ts': {
    plugins: ['typescript', 'typescript-operations'],
      config: {
        flattenGeneratedTypes: true,
      },
  },
};
export default config;

他にも @graphql-codegen/typed-document-node@graphql-codegen/typescript-msw を使えばもう少し短く handler を書けます (詳しくは以下の記事を参照)。

tech.smartshopping.co.jp

the-guild.dev

本題: Relay でも msw の handler を型付きで書きたい

上記のような型付きの handler を、Relay を導入しているプロダクトでもやろうと思うと、実は一筋縄ではいきません。

というのも、Relay に備わっている @argumentsDefinition@refetchable といったディレクティブを、graphql-codegen が正しく処理できないためです。Relay にはいくつかの特別なディレクティブがあるのですが、このうちのいくつかが発行されるクエリやレスポンスの形式に影響を与える可能性があるのです。例えば fragment の定義の中に @refetchable ディレクティブを書くと、その fragment の単位でデータの再 fetch をする用のクエリを新たに定義できます (説明が難しいので詳しくは公式ドキュメントを読んでください)。

relay.dev

一方で graphql-codegen 自体は、そのままでは Relay 固有のディレクティブを正しく理解できません。そのため、Relay を導入しているプロダクトで msw の handler を型付きで書こうにも、クエリの response /variable の型定義がコード生成できず、どうにもらならない...という事態になってしまいます。

解決策: relay-compiler に型定義をコード生成させる

考えてみると当たり前なのですが、Relay の公式グッズなら Relay 向けに書かれたクエリを処理できるので、素直にそれを使えば良いです。relay-compiler という公式 CLI ツールがクエリの定義を読み取って TypeScript 向けのコード生成をしてくれるようになっていて、その中にクエリの response /variable の型定義 (XxxQuery$dataXxxQuery$variables) も含まれています。

ただ、Relay が生成するクエリの response の型定義は、デフォルトだと fragment masking 対応の特別な形式の型定義になっています。クエリが fragment で分割されていると、その fragment で分割されている分の field の型が、クエリの response の型定義に含まれていません。そのため、これをそのまま msw の graphql.query に渡しても、上手く機能しません。

そこで、@raw_response_type ディレクティブの出番です。これをクエリに対して付けると、従来の fragment masking 対応の response の型定義とは別に、fragment masking のない形式の型定義も生成してくれます。

// src/pages/index.tsx
import { graphql } from 'react-relay';

// クエリ名の後ろに `@raw_response_type` を付ける
const BlogTopQuery = graphql`
  query BlogTopQuery @raw_response_type {
    blog {
      ...BlogHeaderFragment
    }
    entries {
      ...EntryCardFragment
    }
  }
`;

export function BlogTopPage() {
  /* ... */
}
// src/mocks/handlers.js

import {
  // `XxxQuery$rawResponse` という名前で fragment masking のない形式の
  // 型定義が生成されているので、import してくる。
  BlogTopQuery$rawResponse,
  BlogTopQuery$variables,
} from '../src/__generated__/BlogTopQuery.graphql';
import { graphql } from 'msw';

export const handlers = [
  // handler に渡す
  graphql.query<
    BlogTopQuery$rawResponse,
    BlogTopQuery$variables,
  >('BlogTopQuery', (req, res, ctx) => {
    return res(
      ctx.data({
        blog: { title: 'My Blog' },
        entries: [
          { id: '1', title: 'My First Entry' },
          { id: '2', title: 'My Second Entry' },
        ],
      }),
    );
  }),
];

別解: @graphql-codegen/relay-operation-optimizer を使う

@graphql-codegen/relay-operation-optimizer というグッズがあって、これを使うと graphql-codegen でも Relay のディレクティブを処理できるようです。

the-guild.dev

しかし「List of Features」をよく見てみると、@argumentsDefinition@arguments には対応しているものの、@refetchable などその他ディレクティブには対応していないようです *1

あまり Relay の機能を駆使していないプロジェクトならこれでもやっていけるかもしれません (とはいえ素直に relay-compiler を使ったほうがトラブルに見舞われなくて良いと思います)。

あとがき

@raw_response_type ですが、API ドキュメントでは全く触れられておらず、用語集のページ *2 や mutation 関連のページ *3 でちょっと出てくるくらいで、全く公式ドキュメントに説明がなくて、ちょっと不安な感じがしますが... とはいえ id:mizdra の手元ではちゃんと動いてそうです。同じように Relay で msw の handler を型付きで書きたい人が居たら参考にしてみてください。

npm package を実装するための自分専用テンプレートリポジトリを作った

npm package を作る度にイチから開発環境の構築をしていて大変だったので、自分専用のテンプレートリポジトリを作りました *1

github.com

せっかくなので、テンプレートの特徴とか、どういうこと考えながら作ったとか紹介してみます。

はじめに: 基本的な技術スタック

  • npm
  • TypeScript
  • Node.js Native ESM
  • Prettier
  • ESLint
  • Vitest
  • Renovate
  • GitHub Actions
  • vscode 向けの各種設定ファイル (extensions.json, launch.json, settings.json)

GitHub の「テンプレートリポジトリ」機能を使う

GitHub にそれっぽい機能があったので使ってみました。

docs.github.com

「Use this template」というボタンが出て便利です。

「Use this template」ボタンから、テンプレートを利用したリポジトリを作成できる。

yarn/pnpm ではなく npm を使う

以前は yarn や pnpm も使ってましたが、npm だけでも十分だったので npm にしました。yarn/pnpm にあった機能は、以下のような代替機能に移行すれば良いかなと思ってます。

npm workspace を使っている人そこまで居ないはずなので、安定性が気になってましたが、happy-css-modules で試している限りはちゃんと動いてそうでした。細かいバグを踏み抜く可能性はあると思いますが、一般的な使い方をしている限りはそう困らないんじゃないかなと。

tsconfig.json は tsconfig/bases を使って書く

tsconfig.json の設定値は色々あって書くのかなり難しくて、id:mizdra も苦労してました。しかし最近は tsconfig/bases というコミュニティ管理の共有 config 集があるようで、良い感じだったのでこれに乗っかっることにしてみました。

github.com

いくつか config があるのですが、npm-package-template では @tsconfig/strictest@tsconfig/node18 の 2 つの config を使ってます。

// tsconfig.json
{
  "extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node18/tsconfig.json"],
  "exclude": ["node_modules", "dist", "bin"],
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "node16",
    "noEmit": true,
    /* Lint Options */
    "noUnusedLocals": false, // Delegate to @typescript-eslint/no-unused-vars in eslint
    "noUnusedParameters": false, // Delegate to @typescript-eslint/no-unused-vars in eslint

    /* Debug Options */
    "locale": "ja"
  }
}

設定値もかなり妥当なものになっていて、ほぼそのまま受け入れれば十分でした。noUnusedLocalsnoUnusedParameters など lint 系の設定が ON になっていたので、id:mizdra はそれだけ OFF にしました (未使用変数の警告は eslint で検査するほうが細かい調整ができるので)。

コミュニティ標準に乗っかって楽ができて良いと思います。

*.ts だけでなく *.jstsc で型検査する

コードを *.ts で書いて tsc で型検査するのは当たり前ですが、npm-package-template では更に一歩踏み込んで、*.js の型検査もやってます。tsc には JSDoc の型アノテーションのコメントを書いて型付けされた *.js を型検査する機能があって、それを使ってます (@tsconfig/strictest を extends すると自動で ON になります)。

zenn.dev

*.ts の型アノテーションと違って、*.js の JSDoc の型アノテーションは書き方が独特です。TypeScript に慣れ親しんでいる人でも、書き方を調べながら書かないといけないくらいには、書き方が違います (id:mizdra も上記の id:qnighy さんの記事を見ながら書いているくらいなので...)。

難しいですが、id:mizdra としては型があったほうが嬉しいですし、書きまくっていればそのうち書き慣れるはずなので、まあ良いかなということで *.js の型検査をするようにしてみました。

自分用のテンプレートリポジトリだからこういう思い切った判断をしてますが、複数人で開発するようなリポジトリでこれを入れるのはガッツがないと難しいと思います。

tsconfig.jsoninclude オプションは省略する

tsconfig.jsoninclude オプションを使うと、そのオプションで指定されたパターンにマッチするファイルと、そこから辿れるファイルが tsc の型検査の対象となります。

{
  "include": ["src/*"],
  "compilerOptions": {
    "lib": ["es2019"]
    // ...
  }
}

つまり以下のようなディレクトリ構成があったとき...

my-app/
├─ asset/
│  ├─ locale.ts
├─ src/
│  ├─ util/
│  │  ├─ math.ts
│  │  ├─ fs.ts (実装途中のモジュールで、まだどこからも依存されていないという設定)
│  ├─ index.ts  (src/util/math.ts, asset/locale.ts に依存)
├─ package.json
├─ .eslintrc.js
├─ tsconfig.json (`"include": ["src/*"]` が指定されてる)

以下のファイルが tsc の型検査の対象になります。

  • src/index.ts ("include": ["src/*"] にマッチするため)
  • src/util/math.ts (src/index.ts から辿れるため)
  • asset/locale.ts (src/index.ts から辿れるため)

一方、以下のファイルは型検査の対象になりません。

  • src/util/fs.ts
  • .eslintrc.js

そのため、src/util/fs.ts などに型エラーがあっても CI をすり抜けてしまいます。tsserver (vscode に組み込まれている TypeScript の Language Server) も src/util/fs.ts をどの tsconfig.json を使って型検査すればよいか分からないので、エディタ上で実行される型検査もおかしなことになります。

"include: ["src/*"] と書く人は稀だと思いますが、(TypeScript 公式ドキュメントの include オプションの説明 にも書いてある) "include: ["src/**/*"] を書いている人は結構居るかもしれません。"include: ["src/**/*"] であれば src/util/fs.ts も型検査されるので、ほとんどの人はそれで支障ないと思います。ただ、"include: ["src/**/*"] でも .eslintrc.js は型検査されません。僕としてはありとあらゆるファイルを型検査して欲しいので、これでは困ります。

そこで npm-package-template では、include オプションを省略することにしました。省略すると "include: ["**/*"] 相当の挙動になり、.eslintrc.js も型検査対象に含まれるようになります。

{
  // 省略する
  // "include": ["src/*"],
  "compilerOptions": {
    "lib": ["es2019"]
    // ...
  }
}

余談ですが、モノレポで、npm workspace などを利用する場合、個々の workspace ごとに型検査に使用する tsconfig.json を変えたいことがあります (workspace ごとに lib を変えて型検査したいなど)。そういう場合は exclude オプションを併用したり、workspace ごとの tsconfig.json も併置する必要があります。この話をすると長くなるので、詳しくはまた別の機会に。

ビルドは tsc で行う

ビルドツールには tsc を使ってます。一般的な Web フロントエンドアプリケーションでは Webpack や Vite など、いわゆる bundler を使ってビルドしますが、別に npm package では bundle する理由はないので、bundler は使いません。*.ts => *.js のトランスパイルは tsc でできるので、それで十分です。

ビルド用の tsconfig.json は型検査用のものと別に用意する

ビルド時は noEmit を外したり、追加でいくつかのオプションを微調整したいことがあるので、型検査用のものとは別の tsconfig.json を用意してます。

シンプルに型検査用の tsconfig.json を extends しつつ、ビルド用向けにカスタマイズしているだけです。

{
  "extends": "./tsconfig.json",
  "include": ["src/**/*"],
  "compilerOptions": {
    "noEmit": false,
    "outDir": "./dist",
    "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/.
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true
  }
}

declarationMap はあまり知られてないオプションですが、めっちゃ便利なので付けておくと良いです。

rootDirdist ディレクトリ内の構造がうっかり変わって package が壊れないよう、保険として付けておくと良いです *2

Node.js Native ESM で書く

もう 2023 年だし全部 Node.js Native ESM で書けば良いでしょう、ということでコードは Node.js Native ESM で書くようにしました。本当は Dual Packages (CommonJS と ES Modules) に対応するのが望ましいと思いますが、そのための設定の手間や、Dual Package Hazard の回避のために、一旦 Pure ESM を前提としたテンプレートにしてます。やる気が出たら Dual Packages 対応するかも...。

関連:

quramy.medium.com

yosuke-furukawa.hatenablog.com

Prettier/ESLint/Renovate の設定は shareable config 化したものを使う

自分専用 shareable config を育ててるので、それを使ってます。

mizdra-style npm-scripts に従う

以前以下の記事で紹介した npm-scripts の書き方を npm-package-template でも採用してます。どんなプロジェクトでもこの書き方通りにやれば上手くいくので気に入ってます。

www.mizdra.net

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "dev": "npm run build && node bin/example-command.js",
    "lint": "run-s -c lint:*",
    "lint:tsc": "tsc",
    "lint:eslint": "eslint .",
    "lint:prettier": "prettier --check .",
    "test": "vitest"
  },
  // ...
}

.xxxignore に書かれているファイル以外全て lint/format する

eslint .prettier --check . で lint/format します。eslint src/**prettier --check src/** とは書きません。何故なら lint 漏れが発生する可能性があるためです。加えて eslint や prettier の vscode 拡張は、原則として .eslintignore.prettierignore に書かれているファイル以外全て lint/format します。そのため、たとえ npm-scripts に eslint src/** と書いていても、それを無視して src/ ディレクトリ以外のファイルも lint/format してしまいます。

vscode の拡張機能とも lint/format 対象のファイルに違いが出てしまうので、eslint src/**prettier --check src/** と書くのは避けるのが良いです。

参考: ESLint, Prettier, VS Code, npm scripts の設定: 2021春

検査内容ごとに GitHub Actions の job を分ける

1 つの job で lint => test => build と順番にまとめてやると、どれか1つがコケた時点で、後続の検査が実行されなくなってしまいます。例えば lint でコケると、test や build が実行されません。仮に test や build がコケる状態あっても、その時点ではそれが発見されず、lint が通るよう直したあとに気づくという... あるあるだと思います。

これでは困るということで、npm-package-template では 検査内容ごとに job を分けることにしました。

「そんな調子で job の数を増やしていったら、GitHub Actions の同時並列実行数の上限に当たって困るんじゃない?」という意見もあるかもしれませんが、まあそうなったらその時考えれば良いかなと。そもそも今回は lint/test/build の 3 つしか job ないので大した量じゃないはず。

vscode 向けの設定ファイルを用意

id:mizdra は普段 vscode を使ってコーディングしているので、そのコーディングが快適にできるよう、いくつか設定ファイルを用意しました。

最後の .vscode/launch.json がイチオシで、これによって breakpoint を仕掛けながら npm package をデバッグできます。

breakpoint を仕掛けながらデバッグする様子。

たった 13 行の設定ファイルを用意するだけで動きます。手間の割に得られるものが大きくてオススメです。

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "dev",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "dev"]
    }
  ]
}

テストランナーには Vitest を使う

今までずっと Jest を使ってきましたが、Node.js Native ESM のコードを Jest でテストしようと思うと、すごく複雑なセットアップが必要で、苦労してました。最近 Vitest を使ってみたところ、ほぼゼロコンフィグで Node.js Native ESM のコードのテストができて感動したので、Vitest に移行することにしました。

Vitest for VSCode」という vscode 上からテストを実行する拡張機能もあって、新興ライブラリながらエコシステムも整いつつあるように見えてます。まあでもよく触ってみると vscode-jest にはあるけど Vitest for VSCode にはない機能の存在に気づいたり。適時 Issue を立てたりとフィードバックしながら使うことになりそうです。

テストファイルはテスト対象ファイルの横に併置する (コロケーション)

src/math.ts のテストファイルを src/math.test.ts に置くという話です。要は以下の実践です。

www.mizdra.net

package.jsonfiles field を設定する

package 化したときに含まれるファイルを package.jsonfiles field で指定してます。

{
  // ...
  "files": [
    "bin",
    "src",
    "!src/**/*.test.ts",
    "!src/**/__snapshots__",
    "dist"
  ]
}

bindist だけあれば良いように思うかもしれませんが、*.ts を package 内に保持しておかないと tsconfig.jsondeclarationMap が機能しないので、src も必要です。ただ、src を丸ごと指定すると、コロケーションしているテストファイルも混じってしまうので、!src/**/*.test.ts!src/**/__snapshots__ も併記してます。

LICENSE や README も含める必要がありますが、それらは npm が自動で含めてくれるので、省略してます。

リリース方法をドキュメント化する

CONTRIBUTING.mdnpm publish コマンドなどを使ったリリース方法を書き留めることにしました。

npm publish のやり方がうろ覚えで、毎回調べていたので...。リポジトリに書いてしまえば間違いようがないはず。コードブロックにすることでコマンドのコピーボタンが表示されるようにしてるのがポイントです。

コードブロックにすることでコピーボタンが出現する。

.github/release.yml を用意する

GitHub のリリースノート自動生成機能を使うために必要なファイルです。

www.mizdra.net

npm-package-template では id:r7kamura さんの方法を真似て、Keep a Changelog の形式のリリースノートが生成できるよう設定してます。

r7kamura.com

あとがき

現代的なテンプレートリポジトリを作ることができて満足してます。あとコードを書きまくって得た知見をこうしてテンプレートリポジトリに集約できたのも良かったです。

テンプレートリポジトリ自体は CC0-1.0 で配布してるので、ご自由にお使いください。

*1:厳密にはリポジトリ自体は 4 年前からあったのだけど、ちゃんと整備してなくて滅んでいたので整備し直した、という背景。

*2:実際にこのオプションを付けてなかったために、とある package で dist ディレクトリ内の構造を変えて壊してしまうという出来事があった。

Twitter に投稿したツイートを Mastodon に転送するようにした

去年の 11 月から続く一連の騒動を受けて、id:mizdra のフォロワーの中でも Twitter から Fediverse に移行してきている人が増えてきた。僕自身は移行するつもりはないけれど、移行したフォロワーが僕のツイートを Fediverse から見れるように、ツイートを Mastodon へと転送するようにしてみた。せっかくなので、そのやり方について書き残しておく。

作戦

IFTTT という「〇〇したらXXする」みたいなピタゴラスイッチをボタンポチポチで作れるサービスがある。これを使い、当該 Twitter アカウントでツイートがされたら、それを契機に Mastodon にトゥートを投稿する、というピタゴラスイッチを組むことにする *1

転送する上での注意点 (2023/4/10 追記)

(トラバで情報を頂いたので追記)

今回紹介する方法では、普段は自動投稿のみをする BOT のようなものを作ることになる。しかし Mastodon インスタンスによっては、BOT の運用方法を規制するルールがあるそうだ。例えば、日本国内で著名な Mastodon インスタンスである mstdn.jp では、BOT からの Public なトゥートは禁止されている。mstdn.jp で今回紹介する方法を利用するなら、トゥートを Unlisted (ローカルタイムラインや連合タイムラインには表示せず、ホームタイムラインにのみ表示) にする必要がある。

bering.hatenadiary.com

インスタンスによってルールも異なるので、本記事で紹介している設定に加えて追加でいくつか設定が必要になるかもしれない。あるいは、そもそも自動転送やマルチポストといったものが禁止されている可能性もある。転送設定をする前にインスタンスのルールを確認するように。

ステップ1. Mastodon でアクセストークンを発行する

IFTTT から Mastodon にトゥートを投稿するには、Mastodon の「アクセストークン」と呼ばれるものが必要なので、まずはその作成から。

Mastodon のアクセストークンは、Mastodon のメニューの ユーザー設定 > 開発 > 新規アプリ から作成できる。

アクセストークンを発行するページの開き方の図解

するとアクセストークンの発行のために必要な情報の入力を求める画面が出てくる。今回は以下の内容を設定して「送信」ボタンを押せば良い。

  • アプリの名前: twitter2mastodon など
    • 発行したアクセストークンが、どんな目的で使われているのかあとから区別できるよう、名前を付けておくための欄
    • 好きな名前を入れれば良い。id:mizdra は「twitter2mastodon」にした。
  • アプリのウェブサイト: デフォルトのままで OK
  • リダイレクトURI: デフォルトのままで OK
  • アクセス権: write:media, write:status だけチェックを入れて、他のチェックは全部外す
    • 発行されるアクセストークンを使ってできることをここで制限できる
    • デフォルトで read, write, follow にチェックが入っているが、今回は要らないので外す
    • 画像の投稿と、テキストの投稿ができるよう、write:media, write:status にチェックを入れる

「送信」ボタンを押すと「アプリが作成されました」、という表示が出てくるはず。アプリ名のところをクリックすると、生成されたアクセストークンを確認できる。

生成されたアクセストークンの確認方法

発行したアクセストークンは後のステップで使うので、どこかにメモしておく。ただしこのアクセストークンが他の人に漏れてしまうと、他の人が自分になりすましてトゥートできてしまうので、取り扱うには十分注意するように。

ステップ2. IFTTT のアカウントを作成する

https://ifttt.com/join から IFTTT のアカウントを作成する。流れでできるはずなので説明は割愛。もうアカウントを持っている人はスキップで OK。

ステップ3. IFTTT で「Twitter でツイートしたら Mastodon に転送する」Applet を作る

IFTTT では「Applet」というものを作って、ピタゴラスイッチのような自動化フローを組んでいく。今回もこの Applet を作る必要がある。IFTTT のページの右上にある「Create」ボタンから作成できる。

Applet の作成画面が出てくるので、以下のような手順で Applet を作っていく:

  1. 「If This」の横の「Add」ボタンをクリック
    1. 連携可能なサービス一覧が出てくるので、「Twitter」を探して選択
    2. Twitter のどのイベントを対象にピタゴラスイッチを起動させるか聞かれるので、「New tweet by you (新しくツイートをした時)」を選択
    3. 「Add new account」から自分の Twitter アカウントと IFTTT を連携
    4. 「Include」欄のうち、「retweets」にチェックを入れる
    5. 入力内容に間違いがないか確認して、「Create trigger」をクリック
      • 以下のようになっていれば OK
      • 「Twitter account」が設定されていて、「retweets」にチェックが入っていればOK
  2. 1 の画面に戻ってくるので、「Then that」の横の「Add」ボタンをクリック
    1. 連携可能なサービス一覧が出てくるので、「Webhooks」を探して選択
    2. 「Make a web request」を選択
    3. フォームが出てくるので以下の情報を入力
      • URL: https://<お使いの Mastodon インスタンスのドメイン>/api/v1/statuses
      • Method: POST
      • Content Type: application/x-www-form-urlencoded
      • Additional Headers: 空で OK
      • Body: access_token=<ステップ1でコピーしたアクセストークン>&status={{Text}}
    4. 入力内容に間違いがないか確認して、「Create action」をクリック
      • 画像のような内容になっていれば問題ない
  3. また 1 の画面に戻ってくるので、「Continue」をクリック
  4. Applet のタイトルを設定するよう指示されるので、Twitter => Mastodon などと入力
  5. 「Finish」を押して Applet 作成完了

これで Twitter でツイートしたら Mastodon に転送されるようになったはず。

ステップ4: Mastodon アカウントを BOT アカウントとしてマークする (オプション)

Twitter のツイートをトゥートする Mastodon アカウントは、いわば Bot である。そういう BOT のトゥートを見たくない人もいるので、その配慮として Mastodon アカウントを BOT としてマークしておくと良い (とブコメで教えていただいた。ありがとうございます。)。

BOT アカウントのマークは ユーザー設定 > プロフィール > 外観 にある 「これはBOTアカウントです」にチェックを入れればできる。

「これはBOTアカウントです」にチェックを入れればOK

あとお好みで ユーザー設定 > ユーザー設定 > その他 から「検索エンジンによるインデクスを拒否する」にチェックを入れたり、「投稿の公開範囲」を 未収載 に設定してみたりすると良いかも。Mastodon のアカウントの表示名を <ユーザ名>@twitter.com みたいにして、マルチポストだと分かりやすくするのも良いと思う。

ひとまずこれですべての設定が完了したはず。お疲れさまでした。

余談: id:mizdra が Mastodon に転送しようと思った背景

id:mizdra は情報収取ツールとして Twitter を利用している。Twitter さえ見ていれば欲しい情報が手に入るように、情報を発信する人を積極的にフォローしている *2。同じように情報収集目的で Twitter を利用している人は大勢居るはずで、おそらく @mizdra のフォロワーにも結構居るのではないかと思っている。そうした人が Fediverse に移行したとき、移行先の Fediverse でも情報収集のために、もともと Twitter でフォローしていた人を、Fediverse でフォローしたくなってくる。しかしご存知の通り、Twitter ユーザを Fediverse からフォローすることはできない。

これは Fediverse に移行する以上仕方のないことだと思う。ただ、同じ情報収取目的で SNS をやっている身としては、少し心が痛む。

id:mizdra が Twitter に投稿したツイートを Mastodon に転送するようにしたのは、少しでもそうした人の助けになればと思ったから。少なくとも僕が Twitter を情報収取目的で使っている以上、同じように情報収集目的で SNS をやっているフォロワーには不便させたくない。

*1:IFTTT の Twitter 連携が突然動かなったらこの方法機能しなくなってしまうけど、まあもしそうなったらその時なんとかするスタイルで。

*2:詳しくは: 個人的 Web フロントエンドスキルの獲得方法 - mizdra's blog

Next.js の getServerSideProps を共通化する

Next.js で開発していると、複数のページの getServerSidePropsで同じようなコードを度々書くことになると思う。例えば 「GraphQL クライアントでクエリを fetch して、そのレスポンスをpagePropsに焼き込むロジック」であったり、「(マルチテナントアプリケーションにおいて) リクエストヘッダーからテナントを特定するロジック」であったり。こうした大部分のページで使われるロジックのコードは、何度も書かずに済むよう、何らかの共通化をしたくなる。

すでに色々な人が似たような動機でgetServerSidePropsを共通化する方法を紹介していると思う。それを参考にしながら id:mizdra も自分流の共通化方法を実践している。何度か Next.js アプリケーションを作っているうちに、自分の型のようなものが身についてきたので、それを紹介してみる。

はじめに結論

こう書いてます。

// lib/get-server-side-props.ts
import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
import { GetServerSideProps, GetServerSidePropsContext } from 'next';

type TenantName = 'Tenant-A' | 'Tenant-B' | 'Tenant-C';

// getServerSideProps の共通化とは関係ないので実装は省略し、型だけ定義しておく
declare function getTenantNameFromHostHeader(context: GetServerSidePropsContext): TenantName | undefined;
export declare function createApolloClient(
  cache: NormalizedCacheObject | undefined,
  tenantName: TenantName,
  context?: GetServerSidePropsContext,
): ApolloClient<NormalizedCacheObject>;

/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` に渡される context の型 */
type WithTenantOuterContext = GetServerSidePropsContext;
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` に渡される context の型 */
type WithTenantInnerContext = WithTenantOuterContext & { tenantName: TenantName };
/** `withTenantGSSP(innerGSSP)` の `innerGSSP` から返すべき pageProps の型 */
type WithTenantInnerPageProps = {};
/** `const outerGSSP = withTenantGSSP(innerGSSP)` の `outerGSSP` から返すべき pageProps の型 */
export type WithTenantOuterPageProps = WithTenantInnerPageProps & { tenantName: TenantName };

/**
 * Host ヘッダーからテナント名を取得し、innerGSSP にテナント名を渡す HoF。
 * また、テナント名をコンポーネントから参照できるよう、pageProps に焼き込む。
 */
function withTenantGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithTenantInnerContext) => ReturnType<GetServerSideProps<P & WithTenantInnerPageProps>>,
): (context: WithTenantOuterContext) => ReturnType<GetServerSideProps<P & WithTenantOuterPageProps>> {
  return async (context) => {
    const tenantName = getTenantNameFromHostHeader(context);
    if (tenantName === undefined) throw new Error('Host ヘッダーからテナントの特定に失敗しました。');
    const innerResult = await innerGSSP({ ...context, tenantName });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        tenantName,
      },
    };
  };
}

type WithApolloOuterContext = GetServerSidePropsContext & { tenantName: TenantName };
type WithApolloInnerContext = WithApolloOuterContext & { apolloClient: ApolloClient<NormalizedCacheObject> };
type WithApolloInnerPageProps = {};
export type WithApolloOuterPageProps = WithApolloInnerPageProps & { initialApolloState: NormalizedCacheObject };

/**
 * Apollo クライアントを innerGSSP に渡しつつ、Apollo クライアントのキャッシュを pageProps に焼き込む HoF。
 * 焼きこまれたキャッシュは、_app.tsx で Apollo クライアントを初期化する際に使われる。
 */
function withApolloGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithApolloInnerContext) => ReturnType<GetServerSideProps<P & WithApolloInnerPageProps>>,
): (context: WithApolloOuterContext) => ReturnType<GetServerSideProps<P & WithApolloOuterPageProps>> {
  return async (context) => {
    const apolloClient = createApolloClient(undefined, context.tenantName, context);
    const innerResult = await innerGSSP({ ...context, apolloClient });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        initialApolloState: apolloClient.cache.extract(),
      },
    };
  };
}

export type WithGlobalOuterContext = GetServerSidePropsContext;
export type WithGlobalInnerContext = WithTenantInnerContext & WithApolloInnerContext;
export type WithGlobalInnerPageProps = WithTenantInnerPageProps & WithApolloInnerPageProps;
export type WithGlobalOuterPageProps = WithTenantOuterPageProps & WithApolloOuterPageProps;
/** よく使われる HoF を合成した HoF */
export function withGlobalGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithGlobalInnerContext) => ReturnType<GetServerSideProps<P & WithGlobalInnerPageProps>>,
): (context: WithGlobalOuterContext) => ReturnType<GetServerSideProps<P & WithGlobalOuterPageProps>> {
  return withTenantGSSP(withApolloGSSP(innerGSSP));
}
// pages/_app.tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import { createApolloClient, WithTenantOuterPageProps, WithApolloOuterPageProps } from '../lib/get-server-side-props';

// withGlobalGSSP などで囲われていれば、OuterPageProps 相当のプロパティが pageProps に入っているはず。
// ただしページによっては withGlobalGSSP などで囲ってなかったりするので、 Partial<> で optional にする
type CustomAppProps = AppProps<Partial<WithTenantOuterPageProps & WithApolloOuterPageProps>>;

export default function App({ Component, pageProps }: CustomAppProps) {
  if (!pageProps.tenantName) throw new Error('pageProps.tenantName is required.');
  const apolloClient = createApolloClient(pageProps.initialApolloState, pageProps.tenantName);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { withGlobalGSSP } from '../lib/get-server-side-props';
import { gql, useQuery } from '@apollo/client';

const QUERY = gql`
  query TopPage {
    hello
  }
`;

export default function Home() {
  const { loading, error, data } = useQuery(QUERY);
  return (
    <main>
      <p>top page.</p>
      <div>
        {loading && <p>loading...</p>}
        {error && <p>error: {error.message}</p>}
        {data && <p>data: {JSON.stringify(data)}</p>}
      </div>
    </main>
  );
}

export const getServerSideProps = withGlobalGSSP(async (context) => {
  await context.apolloClient.query({ query: QUERY });
  console.log(context.tenantName);
  return { props: {} };
}) satisfies GetServerSideProps;

以下のリポジトリにコード全文があります。

github.com

ポイントをかいつまんで紹介

  • それぞれのページの固有の getServerSideProps (GSSP) のロジックが実行される前後で共通のロジックを呼び出したいことがあるので、High-order Function (HoF) として共通化してます
    • 簡単に言うと関数を引数にとって、関数を返す関数のこと
    • (React Hooks 登場以前の) Redux 触ったことある人なら馴染みあると思う
  • innerGSSP がページの固有の GSSP で、それを HoF に渡すと、「共通ロジック前半部分の実行」=>「innerGSSP の実行」=>「共通ロジック後半部分の実行」みたいな順で実行されていく
    • 「共通ロジック後半部分」で得たデータを innerGSSP に渡したり、innerGSSP の返り値を「共通ロジック後半部分」で拡張してから返したりできる
  • よく使う HoF を合成した withGlobalGSSP という HoF を用意してる
    • export const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps; と書いて使う
  • Inner と Outer の型をちゃんと表現してるので、 HoF の適用順序を間違えたら型エラーが出るようになってる
    • HoF の適用順序が正しい時 (右) と間違えた時 (左) の比較
    • withApolloGSSPtenantName を要求するので、先に withTenantGSSP を呼ばないといけない

感想

  • withApolloGSSPwithGlobalGSSP などを使うページコンポーネント側はすごくスッキリする
    • けど HoF 実装側は大変なことになってる!!!
    • 何とかならないものか
  • 厳密に型付けするのを諦めて、もう少しナイーブにやったらメンテナンスしやすい形にできないかな
  • 皆さんはどうしてますか?

YAPC::Kyoto 2023 に参加してきた

登壇とかではなく、いち聴者として参加してきました。

yapcjapan.org

前日祭も参加していて、土日での京都滞在でした。

yapcjapan.connpass.com

僕と YAPC

YAPC への参加は去年の YAPC::Japan::Online 2022 に続いてとなり、YAPC::Kyoto 2023 で2回目です。オフラインの YAPC は初めてでした。

また、別の話として新卒入社のタイミングがコロナと重なっており、参加人数が数百人超える大きなカンファレンスに出たことがほとんどありませんでした (入社前のものを含めると HTML5 Conference 2018 と builderscon tokyo 2019 くらい?)。大きなカンファレンスへの参加が4年ぶりということで楽しみにしてました。

印象深かったセッション

moznion さんの廃墟の話が印象深かったです。出てくる話全部に思い当たる節があるというか、トークの引き込み力がすごかった。

よく面白い発表をなさっている方なのはご存知だったけど、まだ生でその発表を聞くことはできてなかったので、今回直接発表を聞けてよかったです。

docs.google.com

あと mackee さんのデプロイの話も印象深かったです。CGI 時代からどうデプロイの仕方が変遷してきたか、今と昔でどこがどう変わったのか、デモを交えながら丁寧に解説されていてすごく良かった。あと当時の技術的背景の紹介も挟あっていて、なぜその技術がそのタイミングで出てきたのかが納得できる形になっていたのも良かったです。僕のベストトークはこれです。

speakerdeck.com

他にも Hono の 2 セッションも良かったです。個人でカッコいいソフトウェアを作るのはすごく憧れますね。usualoma さんの Hono の内部のルーターの最適化の話が結構面白くて、RegExpRouter がどういうアイデアをもとに作られているかとか、あえて単純な方へと割り切りをしたことで他の最適化が活かせるようになったりとか、なるほどと思いながら聞いてました。

speakerdeck.com

speakerdeck.com

オフラインならではの出来事

セッションを聞いていると dankogai さんが観客席からちょくちょくツッコミを入れられていて、「これがあの Dan The Comment か!」となっていました。YAPC::Japan::Online 2022 のときはリモートゆえにツッコミ不能だったので、すごくオフラインイベントっぽさがありました。あと休憩時間になると廊下に無数にサークルが発生して皆ワイワイ話されていて、あれもオフラインイベント感 MAX でした。

builderscon 以来ぶりの人や、インターネットではよくお見かけするけど物理では面識のない人とかも結構いて、そういう方と挨拶できたのは良かったですね。「ブログいつも読んでます。」とお声がけいただくことも多くて嬉しかったです。

イベントの夜

前日祭があった夜に Hono Conference #1 に参加しました。Hono 使ったことなかったのですが、「使ったことない人もぜひ!」とのことだったので堂々と参加してきました。

初対面の方ばかりだったのですが、ずっと夢中になって話し込んでた!何してたか忘れつつあるけど、Hono contributor 同士の Hono の開発の話を聞いたり、chimame さんと情報共有や Prisma の話をしたりしていた気がする。あと uzulla さんとはオンライン越しに顔を合わせることは何度かあったけど、オフラインで会うのは初で、お会いできてよかった。むちゃくちゃ楽しかったです。

2日目の夜ははてなオフィスに人が集まってて盛り上がってた。結構色々な人とお話した記憶。yusukebe さんに「Hono の tRPC インテグレーション、最近推してますが実際のところどれくらいイケてるものなんですか」と聞いたら Hono でコードを書きながら Hono の RPC モードがどういうものだとか、その裏側はこうなっているとか解説してくださって、お互いにあーだこーだ言っててすごく盛り上がった。あれはよかった。こんなに Hono のこと話してるのに僕が Hono 使ってないの勿体ないので、何とかして普段使いの技術セットにねじ込みたい。

まとめ

面白いセッションもたくさん聞けて、オフラインイベントならではの楽しみも味わえて、めちゃめちゃ楽しかったです! イベントを運営してくださったスタッフの方々、それを支えてくださったスポンサーの方々に感謝です。次回の YAPC もぜひ参加したいです。

あと pastak さんには登壇してくださいとツッコまれてすみませんとなってたので、次回は登壇したい!! まずは Proposal を出すところから...

CPU シミュレータを用いて継続的ベンチマークを安定化させる

id:mizdra は eslint-interactive というツールをメンテナンスしています。このツールを使うと、多数の ESLint エラーを効率的に修正できます (詳しくは以前書いた記事を見てください)。

www.mizdra.net

eslint-interactive では「中規模〜大規模なコードベースであってもキビキビ動く」を大事にしてます。その一環として、eslint-interactive には CI (GitHub Actions) でベンチマークを取り、以前から大きく劣化していたら CI を fail させる仕組みがあります。

しかし CI で実行するためにノイズが大きく、よく誤検知が発生してました。そこで最近 CPU 命令数と CPU キャッシュのヒット率 をメトリクスにしたベンチマークへと移行しました。これにより、約 200% あったノイズが約 3.68% に減少し、安定してベンチマークを取れるようになりました。

非常にニッチな話なのですが、面白いかなと思い紹介してみます。

移行前のベンチマークについて

移行前のベンチマークでは、以下のような Node.js で書かれたスクリプトファイルを実行してベンチマークを取ってました。

github.com

スクリプトは lint 対象となるファイルをファイルシステム上に生成し、それに対して eslint-interactive を実行して lint エラーを修正する作りになってます。この修正に掛かる時間を performance.now で計測しbenchmark-action/github-action-benchmark というベンチマーク結果を視覚化する Actions に渡しています。

実際に視覚化されたベンチマーク結果は以下の URL から確認できます ([OBSOLETE BENCHMARK] というラベルが付いたものが、移行前のベンチマーク結果です)。

視覚化されたベンチマーク結果。commit ごとのメトリクスの変化が確認できる。

見ての通りメトリクスが波打っているのですが、ここでパフォーマンスの改善や劣化が起きている訳ではなく、ほとんど全てがノイズによるものです。いくつか前提条件の異なる数種類のベンチマーク結果があるのですが、その中には 200% のノイズ (最大値 / 最小値 * 100 - 100 から算出) を含むものもあります。

200% のノイズを含むベンチマーク結果。

eslint-interactive では 150% メトリクスが悪化した際に CI を fail させるようにしていたのですが、このノイズのせいで contributor から貰った PR で誤検知が発生して contributor を混乱させてしまったりと、よくトラブルを引き起こしてました。

ノイズが発生する理由とその大きさ

一般に、クラウド管理された CI サービスでは、物理マシンの上に仮想マシンが構築されていて、その仮想マシンの上で job を実行します。物理的なマシン 1 台を複数のユーザ・複数の job で共有するので、非常に大きなノイズが発生します。

実際に、Travis-CI で発生するノイズを調査した記事があり、これによると 50% のノイズが発生することが一般的だとされています (しかも 10,000% のノイズを持つ 4 つの外れ値を除外した上で...)。

bheisler.github.io

Note that there were four benchmark results in the cloud set with percentage differences greater than 10,000% which I’ve removed as outliers. Those were not included in the calculations above; if they were included the cloud numbers would be substantially worse. I opted to remove them after inspecting them and finding inconsistencies in those benchmark results which lead me to suspect that the logs were damaged. For example, one benchmark shows the time for each iteration increased by more than 200x but the throughput for the same benchmark appears to have increased slightly, rather than decreased as one would expect.

... many of the comparisons show shifts of +-2%, roughly similar to the noise observed in local benchmarks. However, differences of as much as 50% are fairly common with no change in the code at all, which makes it very difficult to know if a change in benchmarking results is due to a change in the true performance of the code being benchmarked, or if it is simply noise. Hence, unreliable.

あくまで Travis-CI の事例ですが、「ベンチマークの信頼性を損なう程度のノイズがある」ことはどの CI サービスにも言えることかと思います。

パッと思いつく解決策

パッと思いつく解決策は「ベンチマークの試行回数を増やし、その中央値をメトリクスとする」手法です。例えば、ベンチマークの job で10回ベンチマークスクリプトを回し、その中央値をメトリクスとします。しかし、その job が実行される仮想マシンの CPU がたまたまサーマルスロットリングを起こしていた場合、10回の計測値がすべて劣化して中央値もそれに引きづられてしまいます。複数の job に分けて計測してその影響を受けにくくすることもできますが、手間ですし、依然として一定のノイズがあります。

次に思いつく解決策は、自前の CI runners を用意して、そこでベンチマークを実行することです。GitHub Actions でいうところの self-hosted runners です。この手法なら物理的なマシンを専有できるので、かなりノイズを排除できますが、それを用意するための労力や維持費の問題が出てきます。

どちらも eslint-interactive には合わなさそうだったので、他の手段を検討することにしました。

CPU 命令数と CPU キャッシュのヒット率をメトリクスとする手法

なにか良い方法はないかと途方に暮れていたところ、まさに求めていたものはこれだ!という記事を見つけました。

pythonspeed.com

詳しい内容は記事を読んでもらえれば良いですが、いくつか要点を書くと...

  • CPU 命令数が増えたり、CPU キャッシュヒット率が低下すると、一般にパフォーマンスは劣化する
    • CPU 命令数と CPU キャッシュヒット率を見れば、パフォーマンスの変化がある程度わかる
    • 実行時間はブレやすい一方、実行された CPU 命令の数はブレにくい
    • よって壁時計時間や CPU 時間よりも一貫性のあるメトリクスが得られる
  • 「Valgrind」という仮想環境と、「Cachegrind」というCPU キャッシュプロファイラを用いる
    • この仮想環境上でベンチマーク対象のプログラムを実行し、CPU 命令数とキャッシュヒット率を計測する
    • キャッシュというのは L1 キャッシュや L2 キャッシュなどのこと
  • CPU キャッシュのコストがうまく反映されるよう、各層ごとのキャッシュヒット率を重み付けした 1 つの値で比較する
    • ハードウェアによって多少の重みが異なるが、(L1 + D1 ヒット数) + 5 × (LL ヒット数) + 35 × (RAM ヒット数) で十分
    • メトリクスが1つに結合されるので、比較もしやすい

実際に記事の中では、Python で書かれたプログラムを対象にこの手法を適用したところ、0.000001% 以内のノイズに収まったと紹介しています。

なんと sqlite でも長年同様の方法でベンチマークを取っているそうです。

Valgrind/Cachegrind を使った手法を試すには

Valgrind/Cachegrind を使ってCPU 命令数とキャッシュヒット率を計測し、キャッシュコストを考慮した単一のメトリクスを計算する...ところまで自動でやってくるスクリプトを、件の記事の筆者が作っています。手軽に試したいならこれを使うと良いと思います。より一貫性のある結果を得るために ASLR (アドレス空間配置のランダム化) を無効化する機能も入ってます。

github.com

Valgrind/Cachegrind を使った手法のデメリット

もちろん銀の弾丸ではないので、デメリットや注意点があります。

  • 他のソフトウェアとベンチマークメトリクスを比較する用途には使えない
    • CPU 時間を計測している訳ではないので
    • 「1つのソフトウェアが以前と比較して改善したか・劣化したか」の比較のみに使える
  • 遅い
    • 仮想環境で実行するので
    • ただしノイズを排除するために複数回実行する手間はなくなる
  • 実際にユーザが利用する CPU をエミュレート出来るわけではない
  • 命令ごとの実行コストの違いが反映されていない
    • 命令の種類によってコスト異なるはずだが、それが無視されてる
  • CPU 以外のハードウェアのシミュレートがされない
    • 例えばファイルシステムへの I/O はシミュレーターレートされない
    • そういったものがが支配的なプログラムでは、ノイズが大きくなるはず
  • 最新世代の CPU で実装されているような最適化がシミュレートされない
    • 少し古い世代の CPU の最適化がシミュレートされるようになってるため
    • 現実のパフォーマンスと多少ギャップがある
  • 他にも色々...

試してみた

eslint-interactive では、以前からパフォーマンスが改善したか劣化したかがわかれば良いのでマッチしそうな感じがします。そこで、実際にこの手法を試してみることにしました。

github.com

その結果、ノイズが 200% => 3.68% と大幅に減少しました :tada:

ベンチマーク結果のグラフにも、はっきりとその成果が現れています。

新しいベンチマークのグラフ。大きなノイズは見られない。

実際にパフォーマンスを劣化させる変更を仕込んで、CI が fail することも確認できました。きちんとパフォーマンスの劣化を検知できているようです。

今の所大きな外れ値も出ておらず、非常に安定しているように見えています。

試してみて気になったこと

試した上でいくつか気になったことも書いてみます。

  • 実行が遅い!
    • 直接実行すると 3 秒 (ref)、Valgrind/Cachegrind を経由すると 2分46秒 (ref)
    • 仮想環境で実行しているためと思われる...が遅すぎる気もする
  • ランダム性を排除するために、いくつかオプションを渡す必要がある
  • ノイズが思っていたよりも大きい
    • 記事の Python プログラムの事例では 10 回の試行で 0.000001% 以内に収まってる
    • しかし eslint-interactive では 30 回の試行で 3.68% のノイズがあった
    • node コマンドに渡すオプションが不十分で、ランダム性が十分に排除しきれてない?
      • V8 や Node.js に詳しい人が見たら何かわかるかも
      • 誰かわかりませんか!
    • eslint-interactive ではファイルシステムへの I/O が支配的なため?
      • インメモリな仮想的なファイルシステムへ書き込むようにしたら良い?
      • 試しにやってみた
      • 確かにファイルシステムへの I/O が原因っぽい
      • しかしインメモリなファイルシステムに fixture を用意する都合上、fixture を作成するコードも Valgrind/Cachegrind 上で実行する羽目になってる
        • CPU 命令数の総量が増える (21 billion instructions => 27 billion instructions) のでその分ノイズの変化量も小さくなる、というバイアスがある点には注意
        • 加えてベンチマークの実行時間が 3分 => 4分に増えた
      • ちょっと導入するか悩ましい

まとめ

  • クラウド管理の CI では、ベンチマークの信頼性を損なうのに十分なノイズがある
  • そうした環境でも安定的にベンチマークを取るには...
    • CPU 命令数とCPU キャッシュヒット率を見れば良い
      • Valgrind/Cachegrind を使う
    • ランタイムのランダム性を排除する
      • ASLR の無効化、seed の固定化など
  • Valgrind/Cachegrind のシミュレートは完璧ではない
    • 現実のパフォーマンスと多少のギャップがある
    • シミュレートされないリソースへのアクセスはノイズの原因となる

思ったよりもノイズを小さくできなかったですが、パフォーマンスの劣化を検知する用途としては十分そうです。これで不安定なベンチマークともおさらばできそうです。

合わせて読みたい


  • 2022/2/13追記
    • 「シミュレーター」が「シュミレーター」になっていたの直しました (全く気づかなかった…) (ブコメありがとうございます)

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.