GitHub Actions で mkr を使う

mkr とは何ですか? MackerelCLI です.

GitHub Actions 上で mkr をセットアップする setup-mkr アクションを作りました.

github.com

こういう感じで uses: susisu/setup-mkr@v1 と一行書くだけで mkr コマンドが使えるようになります.

steps:
- uses: susisu/setup-mkr@v1
- run: mkr org
  env:
    MACKEREL_APIKEY: ${{ secrets.MACKEREL_APIKEY }}

mkr の詳しい使い方はここでは紹介しませんが, mkr throw で何らかのメトリックを投稿したり, mkr wrap でジョブの成否を監視したり, mkr annotations create でイベントを記録したりといったことに利用できるかなと思います.


さてここからは setup-mkr をどう作ったか, あるいはこの手のアクションはどう作れば良いかについて見ていきましょう. setup-mkr のソースコードは 100 行弱しかないので, こちらを読んでも簡単に理解できるのではないかなと思います.

setup-mkr でやること

mkr のリリースにはいくつかの環境向けにビルド済みの実行ファイルを含んだアーカイブがアップロードされているので, これを使うことにします. 逆に言えばここにアップロードされていない環境にはとりあえず対応しないという方針です.

setup-mkr がやることのアウトラインを書いてみると以下の通り.

  1. 使用するバージョンを決定する
  2. アーカイブをダウンロードする
  3. アーカイブを展開する
  4. 実行ファイルのあるディレクトリを PATH に追加する

これらを順番に見ていきましょう.

1. 使用するバージョンを決定する

まずはどのバージョンのリリースからアーカイブをダウンロードするかを決定します. ここでやることを素朴に書き出すと,

  • ユーザーからバージョンの指定があればそのバージョンを選ぶ
  • ユーザーからバージョンの指定があればなければ最新のバージョンを選ぶ

となりそうです.

ところが「最新のバージョンを選ぶ」というのが意外と一筋縄ではいきません. というのも, 都合よく最新バージョンとはどのバージョンなのかを取得しにいく先がないのです.

  • リポジトリ内のファイルは, 一般にはリリースされている最新のバージョンのものとは限らない
  • リポジトリの最新のリリースは, 一般には最新のバージョンのものとは限らない
  • リポジトリの全てのリリースを毎回列挙して, さらにそこから最新バージョンを見つける処理を自前で実装するのはやや大変
  • その他に簡単に取得できて, 最新バージョンを指し続けるようなデータはない

setup-mkr の最初のバージョンでは最新のリリースを使うことにしていましたが, とりあえずは動きはするもののなんとも微妙...

ということで都合よく最新バージョンを取得しに行く先を作ってしまいます.

@actions/tool-cache パッケージには, 特定のフォーマットで作ったバージョンのリスト versions-manifest.jsonリポジトリから取得し, バージョンの選択まで行うことができる機能があるので, これに乗っかることにしましょう. この機能はドキュメントには書かれていませんが, actions/setup-go で使われているのを見つけました.

使い方は簡単で, getManifestFromRepo()リポジトリに配置されたリストを取得してから, findFromManifest() でバージョン指定に従ってバージョンを選択するだけです. ついでに ^1.2.3 のような semver の範囲指定もできるようになったり, 実行環境の情報を元にこの後ダウンロードするアーカイブを絞込むところまでやってくれてお得.

import * as tc from "@actions/tool-cache";

const manifest = await tc.getManifestFromRepo("susisu", "mkr-versions", token, "main");
const release = await tc.findFromManifest(version, true, manifest);

バージョンのリストとそれを生成するスクリプトmkr-versions という別のリポジトリに分けました. setup-mkr のリポジトリ内に配置しても良いのですが, こちらにリスト更新のコミットログが延々積み重なってもあまり嬉しくないので...

リストの更新については, 日次で mkr のリリースを確認して, 更新があれば PR が作られるようにアクションを仕込んでいます. ここに PR をマージするという手作業が残っていますが, 壊れたファイルが作られていないかの確認もしたいのでひとまず良しとしましょう.

2. アーカイブをダウンロードする

上の手順でどのアーカイブをダウンロードすればよいかまでは絞り込めたので, 続いてはそれをダウンロードしてきます.

とはいえこれは @actions/tool-cachedownloadTool() というユーティリティが用意されているので, それを使うだけで完成です.

const file = release.files[0];
const filePath = await tc.downloadTool(file.download_url);

3. アーカイブを展開する

これまた @actions/tool-cache のユーティリティを利用するだけです.

const extractedPath = await tc.extractTar(filePath);

extractTar() の他にも extractZip(), extract7z(), extractXar() が用意されているので, アーカイブの種類に応じて使い分けましょう.

4. 実行ファイルのあるディレクトリを PATH に追加する

これも難しいことはなくて, @actions/coreaddPath() という関数があって環境の違いを吸収してくれるので, ここに実行ファイルのあるディレクトリを指定すれば良いだけです.

import * as core from "@actions/core";

// binDirPath はアーカイブを展開したパスなどから作る
core.addPath(binDirPath);

ということでこれで完成です. 難しそうなことは大体ライブラリが既に解決していて便利ですね.

おまけ: キャッシュ

ところで @actions/tool-cache はその名の通りキャッシュのための機能も備えています. とはいえこれは任意のアクションの実行間でキャッシュが利用できるといったものではなく, アクションを実行するホスト上のキャッシュを利用できるという機能なので, GitHub でホストされている環境に最初からキャッシュが準備されているような有名どころのツールか, self-hosted runner を利用しない限りはあまり役に立つことはないでしょう.

使い方は簡単かつ損することはたぶんないので, まあ使っておいても良いでしょう. 詳しくは @actions/tool-cache の README に書かれているのでここでは割愛します.

おまけ: setup-mkr 以外の方法

GitHub Actions で UbuntumacOS のような環境しか使わないのであれば, mkr の README に書いてあるような方法でセットアップするのは簡単だし, これでも良いのでは? はい良いと思います.

強いて setup-mkr の利点を挙げるとすれば,

  • 環境に依らずに簡単に使える
  • 任意のバージョンが指定できる

あたりでしょうか. あまり機能的な差別化はできていないですが, 素振りがしたかっただけなのでまあ良しとします.

まとめ

  • setup-mkr を作りました
  • 上で紹介したような方法で, この手のツールをセットアップするアクションは簡単に作れます
  • とにかく @actions/tool-cache が便利. これだけ覚えて帰ってください

exactOptionalPropertyTypes によせて

TypeScript 4.4 に exactOptionalPropertyTypes というオプショナルなプロパティに関するコンパイラオプションが追加されるのを受けて, そもそもオプショナルなプロパティとは何なのか, どういったときに使うと良いのか, exactOptionalPropertyTypes がどう嬉しいのかを考えてみます.

あらかじめ私個人の立場を明らかにしておくと, 型による安全性を重視する傾向があります (やや過激派寄り).

exactOptionalPropertyTypes については GitHub の discussion issue での議論も参考になるかもしれません.

前提

そもそもオプショナルなプロパティとは

オプショナルなプロパティは, オブジェクト型のプロパティのうち, key?: Type のように ?: で宣言されて, そのプロパティが省略可能であることを表すものです.

type Foo = {
  keyA: number;
  keyB?: string;
};

以下のように関数へ引数を渡すときや変数に代入するときに, プロパティには Type だけでなく undefined を渡したり, あるいはプロパティの存在ごと省略することができます.

declare function doFoo(foo: Foo): void;

doFoo({ keyA: 42, keyB: "xxx" }); // OK
doFoo({ keyA: 42, keyB: undefined }); // OK
doFoo({ keyA: 42 }); // OK

プロパティを参照するときは Type | undefined という型になります.

declare const foo: Foo;
// b: string | undefined
const b = foo.keyB;

オプショナルなプロパティの危険性

さて皆さんご存知の通り TypeScript には構造的部分型という仕組みがあるので, 以下のようにプロパティの数が減る方向へのキャストは合法的に行うことができます. これはいくつかの例外的な状況を除いて, オブジェクトのいくつかのプロパティを無視して扱うことは安全であるためです.

const x: { keyA: number; keyB: boolean } = { keyA: 42, keyB: true };
const y: { keyA: number } = x; // OK

const a = y.keyA; // OK
const b = y.keyB; // Error

この例外的な状況というのはざっくり言えば二種類あって, 一つはオブジェクトのプロパティを列挙する場合と, もう一つはオプショナルなプロパティを扱う場合がそれにあたります. この記事の主題はオプショナルなプロパティについてなので, ここでは後者に注目してみましょう.

実は以下のように, オプショナルなプロパティが増える方向へのキャストは合法ということになっています.

const z: { keyA: number; keyB?: string } = y; // OK

これと先ほどのプロパティが減る方向へのキャストを組み合わせると安全性の問題があることは明らかで, 以下のように z を経由して keyB にアクセスすると, 型の上では string | undefined となっているのに, 実際の値は true であるという矛盾が生じてしまいます.

// b: string | undefined
const b = z.keyB; // = true

困りましたね.

オプショナルなプロパティとうまく付き合っていくには?

上記のような安全性の問題が発生したのは, 安全でないキャストが行われたためでした. このキャストは暗黙的に行われるため, たとえばデータを複数のアプリケーションコンポーネントの間で受け渡していくような場面では, どこかでそのようなキャストがうっかり挟まってしまう可能性があります.

こういった問題を根本から断ち切るためには, そもそもオプショナルなプロパティを使わないという方法が考えられます. オプショナルなデータを表現したい場合には key: Type | undefined のような undefined (または宗派によっては null) との union 型を使います.

type Foo = {
  keyA: number;
  keyB: string | undefined;
};

私の経験上, 複数のコンポーネントでやりとりするような長期間生存するデータについて, このような union 型ではなくオプショナルなプロパティでないと困るというケースにはほぼ遭遇したことがありません.

一方で, オプショナルなプロパティの方が便利なケースはたしかに存在します. ざっと思いつくのは二つで, 一つは関数が名前付きでパラメータを受け取るような場合です.

type Options = {
  optionA?: number;
  optionB?: string;
};

function myFunc(options?: Options): void {
  const a = options?.optionA ?? 0;
  const b = options?.optionB ?? "";
  // ...
}

myFunc();
myFunc({ optionA: 42 });
myFunc({ optionB: "xxx" });

このようなケースであれば, データは通常ごく短期間しか生存せず, キャストが入り込む余地もないため, オプショナルなプロパティを使っても安全と言えるでしょう.

もう一つはオブジェクトの一部のプロパティのみを上書きしてマージするような場合があります. React の setState などが代表的な例ですね.

type Obj = {
  keyA: number;
  keyB: string;
};

// NOTE: Partial<T> は T のすべてのプロパティをオプショナルに変換する
function merge(objA: Obj, objB: Partial<Obj>): Obj {
  return { ...objA, ...objB };
}

const original: Obj = { keyA: 42, keyB: "xxx" };
// obj = { keyA: 42, keyB: "yyy" }
const obj = merge(original, { keyB: "yyy" });

この場合もデータの寿命はごく短いため, 安全性の面での問題はほぼ無く, 適切な使い方であると言えます.

まとめると,

  • 複数のコンポーネントでやりとりするような長期間生存するデータには, もし安全性を重視するのであれば, オプショナルなプロパティを避けるべき
  • 関数の名前付きパラメータやマージされるオブジェクトのように短期間しか生存しないデータには, 安全性の問題は無視できるため, オプショナルなプロパティを使うことができる

というのが私個人の意見です. これに賛同するかどうかは皆さんの自由ですが, TypeScript の仕様上, オプショナルなプロパティを使う場合は, 安全性と利便性を天秤にかけて判断する必要があるということは間違いないでしょう.

exactOptionalPropertyTypes

さて話を戻して, TypeScript 4.4 で追加される exactOptionalPropertyTypes について見てみましょう.

どういう機能?

冒頭で紹介したように, これまでオプショナルなプロパティ key?: Type には, Type または undefined を渡すか, あるいはプロパティの存在ごと省略することができました.

exactOptionalPropertyTypes が有効になっている場合はこの挙動が変更され, Type を渡すまたはプロパティの省略のみが許され, undefined を渡すことができなくなります.

type Foo = {
  keyA: number;
  keyB?: string;
};

declare function doFoo(foo: Foo): void;

doFoo({ keyA: 42, keyB: "xxx" }); // OK
doFoo({ keyA: 42 }); // OK
doFoo({ keyA: 42, keyB: undefined }); // Error

プロパティを参照する場合の挙動は変わりません.

declare const foo: Foo;
// b: string | undefined
const b = foo.keyB;

これまで通り undefined を渡せるようにするには, 以下のように明に union 型を記述する必要があります.

type Foo = {
  keyA: number;
  keyB?: string | undefined;
};

つまり, これまで曖昧だったプロパティの値が undefined であることとプロパティが存在しないことの違いを厳格に区別するようにしよう, というものですね.

何が嬉しいのか?

上で挙げたように, オプショナルなプロパティが比較的安全に使えるのは, データがごく短期間しか生存しない場合でした.

まずは関数の名前付きパラメータを扱う場合を見てみましょう.

type Options = {
  optionA?: number;
  optionB?: string;
};

function myFunc(options?: Options): void {
  const a = options?.optionA ?? 0;
  const b = options?.optionB ?? "";
  // ...
}

myFunc({ optionA: 42 }); // OK
myFunc({ optionA: 42, optionB: "xxx" }); // OK
myFunc({ optionA: 42, optionB: undefined }); // Error

3 つめの呼び出しはこれまではエラーではありませんでしたが, exactOptionalPropertyTypes を有効化したためにエラーになったものです.

この変更は嬉しいのでしょうか? 例えば optionB に渡すための値として, string | undefined 型の変数があったとします.

declare b: string | undefined;

これを exactOptionalPropertyTypes が有効になっている状態で myFunc に渡すにはどうしたらよいでしょうか? 正解は以下です.

myFunc({ optionA: 42, optionB: b }); // Error
myFunc({ optionA: 42, ...(b !== undefined ? { optionB: b } : {}) }); // OK

めんどくさいですね.

また関数の (名前付きでない) オプショナル引数と比較してみると, こちらは以下のように, 引数が渡されなかった場合と undefined を渡した場合を区別しません.

function myFunc2(optionA: number = 0, optionB: string = ""): void {
  console.log(optionA, optionB);
}

myFunc2(42); // 42, ""
myFunc2(42, undefined); // 42, ""
myFunc2(42, "xxx"); // 42, "xxx"

このことを考えると, 名前付き引数についても挙動を揃えて, 省略された場合と undefined を区別しない方が自然で理解しやすいに思われます. そしてこのように区別しない実装をするのであれば, あえて型の上で undefined のみ拒絶するような必要もないでしょう.

こういったことをまとめていくと, 名前付き引数については, 結局 | undefined を明示して, exactOptionalPropertyTypes 以前のデフォルトの挙動に揃えるのが無難な選択肢になるように思われます.

type Options = {
  optionA?: number | undefined;
  optionB?: string | undefined;
};

続いてオブジェクトをマージするような場合を考えてみます.

type Obj = {
  keyA: number;
  keyB: string;
};

function merge(objA: Obj, objB: Partial<Obj>): Obj {
  return { ...objA, ...objB };
}

鋭い読者の皆さんはすでに気がついているかもしれませんが, 従来はこのような実装は安全でありませんでした. 以下のように, オプショナルなプロパティに対して undefined を与えて上書きすることで, 最終的に型とは矛盾した値になってしまうためです.

// obj = { keyA: 42, keyB: undefined }
const obj: Obj = merge({ keyA: 42, keyB: "xxx" }, { keyB: undefined });

この問題は exactOptionalPropertyTypes を有効にすることで綺麗に回避することができます.

merge({ keyA: 42, keyB: "xxx" }, { keyB: undefined }); // Error
merge({ keyA: 42, keyB: "xxx" }, {}); // OK
merge({ keyA: 42, keyB: "xxx" }, { keyB: "yyy" }); // OK

このユースケースには非常に合致していますね.


最後に長期間生存するデータについて見てみましょう. これについてはどうせキャストで破壊できてしまうので, exactOptionalPropertyTypes で厳密に区別したところであまり変わらないように思われます.

const x: { keyA: number; keyB: undefined } = { keyA: 42, keyB: undefined };
const y: { keyA: number } = x;
// z.keyB は存在して値は undefined
const z: { keyA: number; keyB?: string } = y;

先に延べたように, そもそもこういった用途でのオプショナルなプロパティは避けるべきと考えます.

まとめ

オプショナルなプロパティとはそもそもどういったものであったかと, TypeScript 4.4 で追加される exactOptionalPropertyTypes による変化について見てきました.

exactOptionalPropertyTypes が有効になった世界では, 以下のようにオプショナルなプロパティを定義するのが良さそうです.

  • 関数の名前付き引数で用いる場合は, key?: Type | undefined のように undefined を明示しておくのが無難に思われます
  • オブジェクトをマージするような場合は, そのままの key?: Type あるいは Partial<T> が便利に使えます
  • その他のオブジェクトが長期間生存するようなケースでは, そもそもオプショナルなプロパティ自体を避けましょう

おわり.