再帰的型定義ではオブジェクト型のプロパティの遅延評価に注意

この記事の内容は typescript@4.5.4 で動作確認しています.

TL; DR

  • TypeScript において, オブジェクト型のプロパティは必要になるまで評価が遅延される
  • 遅延されていたプロパティの評価が行われるときには, TypeScript 4.5 の末尾再帰の最適化が効かない

例題: FizzBuzz

型レベルで FizzBuzz やります.

type A4 = FizzBuzz<4>;   // = 4
type A6 = FizzBuzz<6>;   // = "Fizz"
type A10 = FizzBuzz<10>; // = "Buzz"
type A15 = FizzBuzz<15>; // = "FizzBuzz"

まずは以下のような型 NumMod を使って自然数とその剰余を表現することにします.

type NumMod = {
  // 長さ N のタプルで自然数 N を表現
  num: unknown[];
  // 合わせて N mod 3, N mod 5 を追跡する
  mod3: 0 | 1 | 2;
  mod5: 0 | 1 | 2 | 3 | 4;
};

この NumMod にしたがって 0 を表現すると以下のようになります.

type Zero = {
  num: [];
  mod3: 0;
  mod5: 0;
};

また NumMod に 1 を足す操作は以下のように定義できます.

type Increment<V extends NumMod> = {
  // タプルに要素を 1 つ追加する
  num: [...V["num"], unknown];
  // 剰余はそれぞれ 1 ずつ進める
  mod3: { 0: 1; 1: 2; 2: 0 }[V["mod3"]];
  mod5: { 0: 1; 1: 2; 2: 3; 3: 4; 4: 0 }[V["mod5"]];
};

NumMod は既に FizzBuzz に必要な剰余が計算されている形になっているため, 出力は単に条件分岐を行うだけ良いです.

type Print<V extends NumMod> =
  V["mod3"] extends 0
    ? V["mod5"] extends 0 ? "FizzBuzz" : "Fizz"
    // タプルは T["length"] で number に変換できる
    : V["mod5"] extends 0 ? "Buzz" : V["num"]["length"];

最後に FizzBuzz<N> は, 与えられた数 N になるまで 0 から順番に 1 を足していき, N まで到達したら出力を行うだけで完成です.

type FizzBuzz<N extends number> = Loop<Zero, N>;
type Loop<V extends NumMod, N extends number> =
  V["num"]["length"] extends N
    ? Print<V>
    : Loop<Increment<V>, N>;

簡単ですね.

type A4 = FizzBuzz<4>;   // = 4
type A6 = FizzBuzz<6>;   // = "Fizz"
type A10 = FizzBuzz<10>; // = "Buzz"
type A15 = FizzBuzz<15>; // = "FizzBuzz"

ここまでのコードはこちら.

末尾再帰の最適化

上で定義した Loop<V, N> は末尾再帰の形になっているため, TypeScript 4.5 であれば末尾再帰の最適化が行われ, 最大で 1000 回まで繰り返しを行うことが可能なはずです.

ということでまずは試しに 100 を入力してみましょう. うまくいけば "Buzz" が出力されるはずです.

type A100 = FizzBuzz<100>;
//          ~~~~~~~~~~~~~
// error TS2589: Type instantiation is excessively deep and possibly infinite.

残念ながら親の顔より見た TS2589 エラー (型のインスタンス化の上限の超過) が出て失敗してしまいました. なぜでしょうか?

オブジェクト型のプロパティの遅延評価

試しに Loop<V, N> を以下のように Print<V> をしないように変更して, 最終的な V の様子を見てみることにします.

type Loop<V extends NumMod, N extends number> =
  V["num"]["length"] extends N
    ? V // ? Print<V>
    : Loop<Increment<V>, N>;

このように変更すると FizzBuzz<100> を計算するだけでは TS2589 エラーは発生しなくなります. 一方で A100 にカーソルをホバーさせるなどして型の内容を見てみると, num はうまく計算されているようですが, mod3mod5any となってしまっています.

type A100 = FizzBuzz<100>;
// type A100 = {
//   num: [unknown, (中略), unknown];
//   mod3: any;
//   mod5: any;
// };

さらにこの mod3mod5 を参照しようとすると, そこで TS2589 エラーが発生します.

type X = A100["mod3"];
//       ~~~~~~~~~~~~
// error TS2589: Type instantiation is excessively deep and possibly infinite.

ここで思い当たるのがオブジェクト型のプロパティの遅延評価です.

Increment<V> の定義を見てみると, 各プロパティの新たな値をプロパティの定義の中で計算しています. この場合, 新たな値は即座に評価されず, プロパティが参照されるなどして必要になった時に初めて評価されます.

type Increment<V extends NumMod> = {
  num: [...V["num"], unknown];
  mod3: { 0: 1; 1: 2; 2: 0 }[V["mod3"]];
  mod5: { 0: 1; 1: 2; 2: 3; 3: 4; 4: 0 }[V["mod5"]];
};

このため, 元々の Loop<V, N> の計算は,

  • V["num"]["length"]N になるまで Increment<V> を繰り返す
  • N に到達したら Print<V> を行うが, このときに初めて遅延されていた V のプロパティの評価を行う

という順番で行われることになります. そして前者では末尾再帰の最適化が行われますが, 後者では最適化が行われないために, 上限を突破してエラーとなってしまうようです.

この遅延評価の機構は再帰的なオブジェクト型を定義する際には有効に働きますが, 今回のケースでは正格に評価してしまうのが適切そうです.

FizzBuzz 修正版

ということで Increment<V> がプロパティを正格に評価するようにします. 方法はいくつかありますが, ここではオブジェクト型をリテラルで書くのではなく, 型エイリアスを経由して作成するようにしてみましょう.

type MakeNumMod<Num, Mod3, Mod5> = {
  num: Num;
  mod3: Mod3;
  mod5: Mod5;
};

type Increment<V extends NumMod> = 
  MakeNumMod<
    [...V["num"], unknown],
    { 0: 1; 1: 2; 2: 0 }[V["mod3"]],
    { 0: 1; 1: 2; 2: 3; 3: 4; 4: 0 }[V["mod5"]]
  >;

このように変更するだけで無事末尾再帰の最適化の恩恵を受けられるようになり, FizzBuzz<100> でも FizzBuzz<666> でも計算できるようになります. よかったですね.

type A100 = FizzBuzz<100>; // = "Buzz"
type A666 = FizzBuzz<666>; // = "Fizz"

完成形のコードはこちら.

読者への宿題

以下を TS2589 エラーを回避しつつ計算できるような FizzBuzz<V> を定義しましょう.

type A2021 = FizzBuzz<2021>; // = 2021
type A2022 = FizzBuzz<2022>; // = "Fizz"

愚直な方法でも, 工夫して計算する方法でも, ハックめいた方法でも実現可能です.

CloudWatch Logs Insights を使って Mackerel 上にアプリケーションのメトリック監視環境を手早く構築する

この記事は Mackerel Advent Calendar 2021 の 15 日目の記事です. 昨日は id:kazeburo さんの mkr plugin install 時の403 API rate limit exceededエラーを回避する方法 でした.


こんにちは id:susisu です. 普段は Mackerel 開発チームでアプリケーションエンジニアをしています.

先日 mackerelio-labs にて cloudwatch-logs-aggregator という Terraform モジュールをひっそりと公開しました.

github.com

この記事では, このモジュールについてと, これを使って AWS 環境で動作するアプリケーションのメトリックの監視環境を手早く Mackerel 上に構築する方法を紹介します.

アプリケーションのメトリックについて

(ここでアプリケーションとは, 汎用的なシステムやミドルウェアとは区別された, ユーザー独自のプログラムを指すこととします.)

動作しているアプリケーションの様子を把握するためには, アプリケーションの可観測性 (observability) を高めることが重要です. 可観測性を成り立たせるための要素としては, 一般的にはログ・メトリック・トレースの 3 つが挙げられますが, このうちメトリックは, 時系列で傾向を把握したり, 定量的な指標を元にアラートを上げたりするのに最も適しています.

典型的なアプリケーションのメトリックとしては, 以下のようなものが当てはまります.

  • 処理されたデータの数
  • データの処理にかかった速度
  • 発生したエラー数

こういったものはログを用いても簡単な監視を行うことはできますが, 割合のようにより複雑な指標に基づいて監視を行ったり, 時間的な変化を把握したりするためには, やはりメトリックの形が便利です.

アプリケーションからメトリックを出力する

アプリケーションからメトリックを出力して利用するためには, 通常アプリケーションに対してなんらかの計装 (instrumentation) が必要になってきます.

例えば最も単純に, アプリケーションから直接 Mackerel にメトリックを投稿するのであれば,

  • メトリックを記録するための仕組みを作る
  • 定期的に Mackerel の API を呼び出してメトリックを投稿する

といった変更をアプリケーションに対して加えることになるはずです.

一方でこういったメトリック出力のための計装は, ログの出力の場合と比較するといくらか複雑です. メトリックの場合, 記録のためにアプリケーションに状態を持たせたり, 投稿のために API の呼び出しのような実装が必要になりますが, ログであれば状態は必要なく, 出力先も標準出力で良いなど, いくぶん簡単な実装で済むはずです.

CloudWatch Logs にアプリケーションのメトリックを記録する

ここではできるだけ手っ取り早くプリケーションのメトリックを記録したい, ということでログの仕組みを利用してしまいましょう.

まずメトリック (特に counter や histogram と呼ばれるような種類のもの) は, 値を含んだ構造化ログを計測のサンプルごとに出力することとします.

例として, なんらかのバッチ処理で処理されたデータの件数のようなメトリックを記録するのであれば, 処理の実行ごとに一行ずつ以下のようなログの出力が想定されます.

{"level":"info","msg": "data processed","count":42}
{"level":"info","msg": "data processed","count":94}
...

このようにして出力されたログは, CloudWatch Logs へ集約することとします. アプリケーションが AWS 環境, 特に ECS や Lambda などで動作している場合であれば, このための設定は簡単に行えるはずです.

CloudWatch Logs に出力されたログに対しては, Insights のクエリを使って高度な集計や分析を行うことができます. このクエリは上記のようなログを集計してメトリックに変換するのに十分な力を持っています.

ここで特に重要なのが stats コマンドで, これはログに含まれる値を使って, count(), sum(), avg() といった統計的な値を計算することができます. 上のバッチ処理のログの例であれば, 例えば期間中の合計処理件数を以下のようなクエリで求められます.

filter msg = "data processed"
| stats sum(count) as processed_count

あとはこのようなクエリを定期的に実行し, Mackerel にメトリックとして投稿するようにすれば, アプリケーションのメトリックの監視・可視化のための準備が整うはずです.

cloudwatch-logs-aggregator を使ってサービスメトリックを投稿する

ここではじめに紹介した cloudwatch-logs-aggregator の出番です.

cloudwatch-logs-aggregator は 2 つの Terraform モジュールから構成され, それぞれ以下のような役割を持っています.

  • cloudwatch-logs-aggregator/lambda: CloudWatch Logs Insights のクエリを発行し, Mackerel にサービスメトリックを投稿するための Lambda 関数の作成
  • cloudwatch-logs-aggregator/rule: 上記の Lambda 関数を実行するパラメータの設定と, 定期的に実行するための EventBridge (CloudWatch Events) のルールの作成

詳しい利用方法については上記リポジトリの README に譲りますが, これらのモジュールを用いることで, CloudWatch Logs のログからメトリックを生成し, Mackerel にサービスメトリックとして投稿する仕組みを簡単に構築することができます.

# Lambda 関数
module "cw_logs_aggregator_lambda" {
  source = "github.com/mackerelio-labs/mackerel-monitoring-modules//cloudwatch-logs-aggregator/lambda?ref=v0.1.0"

  # ...
}

# EventBridge のルール
module "cw_logs_aggregator_rule_my_batch_job" {
  source = "github.com/mackerelio-labs/mackerel-monitoring-modules//cloudwatch-logs-aggregator/rule?ref=v0.1.0"
  
  function_arn = module.cw_logs_aggregator_lambda.function_arn

  # 指定したクエリを発行し
  query = "filter msg = \"data processed\" | stats sum(count) as processed_count"

  # Mackerel にサービスメトリックとして投稿
  service_name = "my-service"

  # ...
}

これで Mackerel にメトリックが投稿できたら, あとはで監視ルールを設定したり, カスタムダッシュボードを作って可視化するだけです!

cloudwatch-logs-aggregator について

この CloudWatch Logs からメトリックを生成する仕組みは Mackerel 開発チームでも実際の運用に使われており, cloudwatch-logs-aggregator はそれを再利用可能な形にして公開したものです.

上ではアプリケーションのメトリックの監視についての使い方を紹介しましたが, ミドルウェアのログからメトリックを生成したり, チェックプラグインを使ったログ監視の代替といった用途にも利用できるかと思います.

現在 cloudwatch-logs-aggregator はアルファ版という形で公開しています. Mackerel の活用により役に立つ機能を提供できるようブラッシュアップしていきたいと考えていますので, 是非ご試用いただき, リポジトリの Issue や Mackerel ユーザーグループの Slack チャンネル #cloudwatch-logs-aggregator などからフィードバックをいただけるとありがたいです.

利用にあたっては以下の点にご留意ください.

  • 上記の通りアルファ版として提供されます. 仕様は今後のバージョンアップで大きく変更される可能性があります
  • CloudWatch Logs Insights のクエリ発行時に AWS の利用料金が発生します (2021 年 12 月現在, 東京 (ap-northeast1) リージョンでは 1 GB あたり 0.0076 USD)

まとめ

  • cloudwatch-logs-aggregator というモジュールを公開しました
  • これを使って, アプリケーションのメトリックの監視のための準備を手早く行う方法を紹介しました
  • 機能をより良くしていくため, フィードバックを募集しています 🙏