AI can fly !!

AI がやりたい Web エンジニアのアウトプット (AI の知識は無い)

【Angular】Reactive Forms でテンプレートからコンポーネントに渡す値の型を指定したい

Angular

はじめに

Angular で Web アプリ開発を行う際、入力フォームを Reactive Forms (リアクティブフォーム) で構築することがよくあります。

一例を示すと、 FormGroup を使用 (form という名前の FormGroup を作成) して、テンプレート側に Text 型の Input 要素 (<input type="text" formControlName="hoge">) を配置し、コンポーネント側でその値を取得する (this.form.controls.hoge.value) という流れになります。

最近とあるプロジェクトで Reactive Forms を使用していて、テンプレート側に配置した様々な Input 要素から指定した型 (string, number, boolean, Date) 通りの値を取得したいというオレオレニーズがありましたので、検証してみました。

動作環境

OS Version
Ubuntu 22.04 LTS

Windows 10 Pro の Windows Subsystem for Linux (WSL 2) を使用

Environment Version
Node.js 20.1.0
npm 9.6.4

※ Docker Hub の Official Image の node:20 を使用

Package Version
@angular/cli 16.0.0
@angular/material 16.0.4

前提として

Typed Forms

Angular ではこれまで Reactive Forms でテンプレートから取得した値は、すべて any 型としてコンポーネントに渡されていました。

せっかく TypeScript でコードを書いているのに…と思う方が多かったからか、 Angular v14 でとうとう Typed Forms が導入され、 Reactive Forms から anynull を駆逐することができるようになりました!

※ Template-driven Form (テンプレート駆動フォーム) には、まだ適用できません…

angular.jp

これまで通りのコードの書き方をすると、コンポーネント側で与えられた初期値の型に応じて FormControl の型推論が行われます。

また、 FormControl のコンストラクタにオプションとして {nonNullable: true} を渡すか、 NonNullableFormBuilder クラスを使用することにより、 null を許容しない (厳密には FormControl#reset 実行時、 null ではなく FormControl のコンストラクタに渡した初期値でリセットする) ことが可能になります。

検証結果

いきなり結論ですが、各型の値を取得する方法をまとめました。

入力フォームの各 Input 要素の値に null を許容するか否かでケースを分けています。

Null を許容しない場合

Component

  • app.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  private readonly fb = inject(FormBuilder);

  readonly form = this.fb.nonNullable.group({
    str: ["", Validators.required],
    num: [0, Validators.required],
    bool: [false, Validators.required],
    date: [new Date(), Validators.required],
  });

  dispTypes(): void {
    console.log(this.form.value);
  }

  cancelStep(e: KeyboardEvent): void {
    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
      e.preventDefault();
    }
  }
}

NonNullableFormBuilder クラスを使用して、 FormGroup 内のすべての FormControl に {nonNullable: true} を適用します。これで FormGroup 内の FormControl に、ユニオン型として null 型が追加されなくなります。

あとは各 FormControl の初期値に、適切な型の値を設定するだけです。

Template

  • app.component.html
<form [formGroup]="form">
  <input type="text" formControlName="str">
  <input type="number" class="input-num" formControlName="num" (keydown)="cancelStep()">
  <input type="checkbox" formControlName="bool">

  <mat-form-field>
    <input matInput [matDatepicker]="picker" formControlName="date" />
    <mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
    <mat-datepicker #picker></mat-datepicker>
  </mat-form-field>

  <button (click)="dispTypes()">
</form>
  • app.component.scss
.input-num::-webkit-outer-spin-button,
.input-num::-webkit-inner-spin-button {
  margin: 0;
  -webkit-appearance: none;
  -moz-appearance: textfield;
  appearance: none;
}

Number 型の Input 要素 (<input type="number">) は、使用する Web ブラウザにより数値を上下するスピンボタンが UI に表示されますが、スピンボタンを使用しない場合は CSS で非表示にすることができます。

また、上矢印キー (ArrowUp) と下矢印キー (ArrowDown) の keydown イベントで Event#preventDefault を実行すれば、入力値の加減算処理をキャンセルすることが可能です。

この辺りはアプリの仕様によりますので、お好みで設定してください。

Console

{str: 'abc', num: 123, bool: true, date: Sun Jun 11 2023 12:15:12 GMT+0900 (日本標準時)}

これでそれぞれの Input 要素から、狙った通りの型の値を取得することができました。異論は認めない。

Null を許容する場合

Component

  • app.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  private readonly fb = inject(FormBuilder);

  readonly form = this.fb.group<{
    str: string;
    num: number | null;
    bool: boolean;
    date: Date | null;
  }>({
    str: [""],
    num: [null],
    bool: [false],
    date: [null],
  });

  dispTypes(): void {
    console.log(this.form.value);
  }

  cancelStep(e: KeyboardEvent): void {
    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
      e.preventDefault();
    }
  }
}

NonNullableFormBuilder クラスは使用せず、 FromBuilder#group へジェネリクスの型引数として各 FormControl の型を指定します。

この際、 string 型と boolean 型については初期値として '' (空文字) や false (未チェック) を渡すことが可能 (UI に影響を与えない) なので、 null を許容するのは厳密には number 型と Date 型になります。

Template

"Null を許容しない場合" の "Template" と同様なので、省略します。

Console

{str: 'abc', num: 123, bool: true, date: Sun Jun 11 2023 12:32:51 GMT+0900 (日本標準時)}

こちらも Null を許容しない場合と同様、狙った通りの型の値を取得することができました。

ただし、 Number 型の Input 要素と Angular Material の Datepicker が未入力の場合、または FormControl#reset などを実行した場合、コンポーネント側で取得できる値は null になります。

まとめ (言い訳)

分かります。

え? Native じゃない Element 混じっているけど?ですよね。

仰る通り、 Input 要素には Date 型 (<input type="date">) が確かに存在するのですが、検証の結果、 Reactive Forms で取得した値は string 型でした。

いや、そうは言っても Angular で日付の入力フォームを作るなら、どうせ Angular Material の Datepicker 使うでしょ?

無いんだよ! Date 型を返す Input 要素は無いんだよ!!

Date 型って、そもそも JavaScript のプリミティブな型じゃないしな!!!

はい、論破~。

すいません、はい、 Symbol はプリミティブです。すいません。ありません。調子に乗りました。 BigInt …ごめんなさい。勘弁してください。

おわりに

今回は Reactive Forms でテンプレートからコンポーネントへ任意の型の値を渡す方法をまとめました。

ここ最近は Web 開発をするならフロントエンドは Angular でってことが多いので、プロジェクトを経る度に新しい学び、発見があります。

Angular のバージョンもいつの間にか 16 まで進んでいて、僕が Angular の勉強を始めた頃はまだ 7 とか 8 だったような記憶…

一時期、 React に浮気しそうになったことがありましたが、長く続けてきたからこそ、少しずつできることが増えてきました。

よく Angular は初心者には敷居が高い、学習コストが高いなどと言われますが、一度しっかり基礎を学んで自分の中でベストプラクティスを作ってしまえば、普段の開発ではあまり悩むことは無いと思います。

むしろ、かっちりとした書き方が定められるのでチーム開発には有利だと思いますし、 Google 主導のコミュニティによって今も積極的にメンテナンスされているので、ビジネスの場においては一定の安心感があります。

まだまだ Angular を使う機会はありそうので、 ChatGPT に仕事を奪われるその日まで、 Angular でフロントエンドを書き続けます。

弊社はフロントエンドに Angular を使用した Web アプリ開発の実績が豊富 (当社比) です。

お仕事のご依頼・ご相談、または、一緒に Web アプリの開発がしたいぜって方は、是非ご連絡をお待ちしています (*•̀ᴗ•́*)و

【最近のお仕事】direct チャットボット開発 (アーキテクチャと技術スタックの紹介)

Architecture

はじめに

今回取り上げるプロジェクトは、先日リリースした国産ビジネスチャット「direct / ダイレクト」のチャットボット開発第二弾です (第一弾は過去にリリース済み) 。

前回に引続き、アーキテクチャと技術スタックについて紹介します。

プロジェクトメンバは実務担当のマネージャーとエンジニア (私) 、あとは顔役の偉い人と今後の運用・保守を担当するエンジニアの合計 4 名です。

チャットボット開発第一弾での私の役割はプロトタイプ開発 & アドバイザー的な立ち位置でしたが、今回はメインの開発エンジニアとして参画しました。

開発期間は設計からリリースまで片手間で二か月程度、実質一か月半程になります。

詳細な仕様については守秘義務があるので記載しませんが、ざっくり言えば、 direct 上でユーザから問合せを受け付け、条件に合致する情報をデータベースから取得して表示するチャットボットを作りました。

アーキテクチャ

Web サービスとして提供されている direct 上で動作するチャットボットとして、サーバ仮想化プラットフォーム (VMware vSphere) 上に構成された仮想マシン (Alma Linux) の Docker 環境に、コンテナとして daab (direct agent assist bot) を構築しています。

ベースコンテナには Node.js の v16 を採用し、 daab の開発ツールとして提供されている daab SDK を使用して開発を行いました。

サーバ仮想化プラットフォーム外の同一ネットワークに存在する DB2 へは、 daab SDK にインストールした npm の IBM DB 用のドライバパッケージを介して接続しています。

仮想マシン内のコンテナ化されたチャットボット (daab) からオンプレのデータベース (DB2) と外部 Web サービス (direct) へ接続するので、ネットワーク関連にやや気を遣う必要はありますが、アーキテクチャ全体としてはシンプルな構成です。

技術スタック

チャットボット

daab (direct agent assist bot)

direct 上で動作するチャットボットの構築にあたっては、 direct の提供・開発元である L is B 社より、 direct とクラウドサービスや企業のシステムをつなぐための bot として daab (direct agent assist bot) が提供されています。

daab のベースになっているのは、 GitHub 社が開発し MIT ライセンスで公開している Hubot というチャットボット開発・実行フレームワークです。

この Hubot に direct 用の拡張が加えられたものが daab で、 Hubot の基本的な機能・スクリプトに加え、 direct 用の Adapter や様々な処理・イベントが追加されています。

direct4b.com

Hubot

Hubot は GitHub 社が開発した Node.js 上で bot を動かすためのフレームワークで、 MIT ライセンスで OSS として公開されています。

Hubot 自体は CoffeeScript で書かれており、 npm パッケージとして提供されていることから、 Node.js が動作する環境であれば手軽に導入することができます。

また、 Hubot は様々な Adapter と組み合わせることで、多くのサードパーティ製チャットツールに接続が可能という特徴があり、 daab には前述の通り direct 用の Adapter がバンドルされています。

bot としての動作は JavaScriptスクリプトを記述しますが、 npm で公開されている様々なパッケージを使用できることに加え、最終的に JavaScriptスクリプトが出力されていれば問題無いので、 TypeScript や CoffeeScript でコードを書くことも可能です。

hubot.github.com

hubot-redis-brain (Redis)

daab のベースである Hubot には、データ永続化の仕組みとして hubot-redis-brain が用意されており、 daab でも使用することができます。

hubot-redis-brain は内部的に Redis が使用されているシンプルな KVS です。

daab (Hubot) は基本的にステートレスなので、チャットのやり取りによる処理の分岐やユーザ毎のデータ保持などを目的として hubot-redis-brain を使用し、ステートフルな会話を実現しました。

github.com

daab SDK

direct 開発元の L is B 社からは、 daab 本体だけでなく daab を開発するための開発ツールとして daab SDK が提供されており、 daab の開発はこの SDK を使用して行います。

daab SDK には daab のスケルトン (開発プロジェクトのテンプレート) 作成や direct へのログインを行うための CLI がバンドルされているので、チャットボットの作成や実行を簡単に行うことができます。

ベースである Hubot と同様、チャットボット (daab) の動作は JavaScript で記述する必要がありますが、今回は daab SDK + TypeScript の組合せで開発を行いました。

daab.direct4b.com

TypeScript

今さら TypeScript の説明はしませんが、言わずと知れた AltJS のデファクトスタンダードJavaScript のスーパーセットである静的型付け言語です。

今回 daab の開発をするにあたって TypeScript 導入の決め手となったのは、 daab のベースとなっている Hubot の型情報 (@types/hubot) が存在することでした。

daab によって拡張されている部分の型宣言は存在しませんが、 Hubot の型情報に合わせてコードを書いていけば概ね問題はありません。

www.npmjs.com

データベース

IBM DB2

IBM 社が開発・販売する RDBMS で、 Oracle Database と並んで歴史あるデータベース管理システムです。

direct 上でのチャットをトリガーに daab から DB2 へ接続し、必要な情報を取得しています。

www.ibm.com

node-ibm_db

npm で公開されている IBM 社の DB へ接続するためのドライバパッケージで、 Node.js 上で動作します。

TypeScript の型情報として @types/ibm_db も公開されています。

github.com

www.npmjs.com

開発環境・開発ツール

もはやお決まりですが、 Windows10 Pro の WSL2 (Ubuntu) 上の Docker 環境に必要なコンテナを構築し、開発を行いました。

Node.js の公式イメージをベースとした daab 開発用コンテナ、 Redis 公式イメージをベースとした hubot-redis-brain 用コンテナ、 DB2 公式イメージを使用したコンテナの三つを Docker Compose で管理し、 daab 開発用コンテナからコンテナ間のネットワークを通じて各コンテナへ接続しています。

Visual Studio Code やそれに付随するパッケージ等々は省略します。

Prettier や ESLint は在って当たり前の空気みたいな存在です。

Prettier については 以前書いた記事 を参照ください。

ai-can-fly.hateblo.jp

ESLint はいつか記事にまとめると言いながら、全然その気が無いことがバレバレです。

A5:SQL Mk-2

GUI で DB のテーブルの確認や SQL 作成・実行が行える、高機能かつ軽量なフリーの SQL クライアントアプリです。

DB2 へのテストデータの登録や SQL の作成・動作確認などに使用しました。

a5m2.mmatsubara.com

IBM Data Server Driver for ODBC and CLI ソフトウェア

Windows 上で動作するアプリから DB2 へ接続するために必要な IBM 社から提供されている ODBC ドライバです。

入手するためには IBM のアカウントを作成する必要がありました…正直めんどい…

www.ibm.com

おわりに

今回のプロジェクトで作成したチャットボットは所謂ルールベース型と呼ばれるもので、ある一定の決まったシナリオ (フロー) に沿った動作のみを行うシンプルなチャットボットでした。

シンプルとはいえ、複数ユーザとのやり取りや想定外のチャットなどへの考慮も加えると、それなりに設計・実装を行う必要があります。

業務で使用するチャットボットの開発ということでなかなか勉強になりましたが、個人的にはそんなことよりも、本来 JavaScript で開発を行う想定の daab のコードを TypeScript で書くことができたというのが大きな収穫でしたw

フルスタックで開発を行っていると、フロントエンドは TypeScript でバックエンドが Python といった場合に言語が異なることによる脳内のスイッチングログが発生するのですが、バックエンドも TypeScript で書くことができればこの問題を解決できます。

ただ、 Google Cloud の Cloud Functions などの FaaS は置いておいて、弊社では Web アプリのバックエンドに Node.js を採用することがほとんど無いので、そもそも TypeScript でバックエンドコードを書くことができないという…

Deno の v1.0.0 が出て 2 年以上経ちますが、まだ Node.js に取って代わるような状況にはなっていないようなので、なんとか Deno には頑張ってもらいたいところ。

もうしばらくは Python も書かないといけないかなぁ… _(:ロ」∠)_

【最近のお仕事】モバイル向け Web アプリケーション開発 (アーキテクチャと技術スタックの紹介)

Architecture

はじめに

先日、担当するプロジェクトでモバイル向け Web アプリケーションの二次開発分をリリースしたので、備忘録も兼ねて現時点で採用しているアーキテクチャと技術スタックについて紹介します。

当 Web アプリはマネージャーとエンジニア (私) の二名体制によるスクラッチ開発で昨年ローンチされ、今回のプロジェクトはチャットや外部 API からのデータ取得等の機能追加を目的としたものでした。

プロジェクトメンバは昨年同様、マネージャーとエンジニア (私) の二名体制になります。

開発期間は設計を含めて片手間で三か月程度、実質一か月~一か月半で仕上げましたが、なんとか納期までにはリリースできたものの完全に工数を見誤りました…

後半は土日も含めて毎日深夜まで…おっと、それでは気を取り直して駆け足で紹介していきます。

アプリの詳細な仕様については、守秘義務などの兼ね合いから記載しませんので悪しからず。

アーキテクチャ

バックエンドは Firebase (および、Google Cloud) をメインとしたサーバレスアーキテクチャを採用し、フロントエンドは Angular を使用した SPA (Single Page Application) で構築しています。

当 Web アプリケーションの要求仕様の一つとして、他社ハードウェアからのメール受信 (Gmail) をトリガとするバックエンド処理がありますが、こちらは Gmail APIGoogle Cloud の Cloud Pub/Sub で対応しました。

今気付きましたが、クラウド環境、バックエンドからフロントエンド、ターゲットとなるモバイル端末 (Android) に至るまで Google で統一されていますね。

アーキテクチャ全体を通して、比較的 Google 製品はシンプルで分かりやすく、とっつきやすさと使い勝手が良いと感じています。

最近、別のプロジェクトで Microsoft Azure を使用していますが、断然 Google Cloud の方が扱いやすいです…

技術スタック

フロントエンド

Angular

SPA で Web アプリケーションを構築するにあたり、開発プラットフォームはこれまで複数の開発実績がある Angular を採用しました。

Angular は開発に必要なパッケージやツールのほとんどが公式にサポートされており、別途サードパーティ製のパッケージなどを組み合わせるコストが発生せず、一体感のある開発体験を行えるのがメリットの一つです。

今回バックエンドとして採用した Firebase との親和性も高く、後述する Angular 公式ライブラリの一つである AngularFire を使用することで、より自然に Angular のスタイルに則したコードを記述することができます。

angular.jp

AngularFire

Firebase を操作するための JavaScript SDK をラップし、 Angular に適したスタイルのコードを書くことができる Angular 公式ライブラリです。

Firebase の各製品を操作する SDK を Service として Component に DI したり、 Angular CLI と連携して Angular アプリを Firebase へデプロイするなど、 Angular で Firebase の開発をするには必須のライブラリと言えます。

github.com

Angular/PWA

PWA とは Web アプリケーションを端末へインストールしたり、オフラインでの操作やバックグラウンドでのプッシュ通知の受信など、ざっくり言えば Web アプリでネイティブアプリのような機能を実現するための技術です。

今回は Firebase Cloud Messaging を使用して送信されたプッシュ通知の受信と表示を行うため、 Angular/PWA を使用しました。

Service Worker のおかげで意図せず初期表示が高速になる恩恵が得られましたが、キャッシュのコントロールなどは行っていないため、アプリ更新時は手動でブラウザを更新するなどのひと手間が必要になります。

ただ、当アプリのリリースサイクルはプロジェクト単位でのリリースとなるため、特に問題にはなりませんでした。

developer.mozilla.org

Angular Flex-Layout

Angular でモバイルファーストなレスポンシブデザインを行うため、 Flexbox CSS とメディアクエリを中心とした Angular 公式パッケージの一つである Angular Flex-Layout を使用しました。

当アプリはモバイル (Android) 端末をターゲットとしていますが、 PC ブラウザでもレイアウトが崩れずに操作が可能なレスポンシブデザインを実現しています。

github.com

Angular Material

Angular 公式の UI ライブラリで、 Material Design を採用したコンポーネントが数多く提供されています。

一つ一つのコンポーネントが Angular の Module として提供されているため、コンポーネントのタグを書くことで簡単に配置でき、プロパティバインディングやイベントハンドリングも容易です。

洗練された Material Designコンポーネントを組み合わせるだけで簡単にそれっぽい Web アプリケーションを作ることができますが、デフォルトのデザインのままでは他との差別化が難しいという点はデメリットと言えなくもありません。

material.angular.io

NgRx

Angular に Redux (Flux アーキテクチャ) のような状態管理の各種機能を提供するライブラリです。

Angular ではシングルトンである Service クラスを使用することで状態の管理を行うことができますが、 NgRx を使用すれば決まったルールの中で一貫した思想に基づいたコードを書くことができます。

プロジェクトやエンジニアごとに思想が異なるオレオレ状態管理は、複数名での開発や運用・保守の場などでは非常に大きな障害・負債になりかねず、プロジェクト初期の学習コストを差し引いてもデファクトスタンダードになっている状態管理技術の導入はメリットがあると思います。

ngrx.io

バックエンド

Firebase

言わずと知れたモバイル・ Web アプリケーション開発プラットフォームで、フルマネージドな Web ホスティングサービスやドキュメントデータベースなどがサーバレス (Saas) で提供されています。

提供元は天下の Google 様です。

必要に応じて複数の製品を組み合わせ、インフラを意識せずに、よりフロントエンドに注力したアプリケーション開発を行うことができます。

firebase.google.com

Firebase Hosting

HTML や CSS などの静的コンテンツだけでなく、 Express などの動的コンテンツもホストできる (らしい) Web ホスティングサービスです。

今回は Angular アプリケーションのホスティングと、後述する Cloud Functions へのリダイレクトを行っています。

firebase.google.com

Firebase Authentication

電話番号やメールアドレス、パスワード等を利用したユーザ認証をアプリケーションへ提供するサービスです。

アプリからのユーザ登録や各種アカウントによるログインもサポートしますが、今回は予め決められたユーザ (メールアドレス) のみ利用可能な Web アプリケーションということで、事前登録されたメールアドレスとパスワードによるログイン認証機能を実装しました。

firebase.google.com

Cloud Firestore

スケーラブルなクラウドホストの NoSQL データベースで、アプリケーションから SDK を通して直接アクセスが可能です。

リアルタイムリスナーという仕組みを利用するとアプリケーション内で保持するデータの変更をリアルタイムで取得することができ、 Angular のデータバインディングと組み合わせることで、データベースの最新データを常に画面に表示し続けることなどが簡単に実現できます。

firebase.google.com

Cloud Functions for Firebase

HTTP リクエストやスケジューラなどをトリガーとしてバックエンドコードを実行するサービスで、所謂 FaaS に分類されます。

Firebase における Cloud Functions の実行環境は Node.js ランタイムのみとなっており、対応言語は JavaScript か TypeScript になります。

サーバを意識せずにバックエンドコードを書けるというのは少人数のチームや小規模プロジェクトにとってメリットが大きく、今回は Web アプリケーションからの HTTP リクエストをトリガーとするメール送信や、後述する Cloud Scheduler での定期実行をトリガーとする API 連携などを実現しています。

また、 Cloud Functions (Node.js ランタイム) のバックエンドの実態は Express で構築されているため、送信したメール本文のリンクからのアクセス先として動的コンテンツを配信するなど、ちょっとした Web サーバのような使い方もできます。

firebase.google.com

Firebase Cloud Messaging

Firebase Cloud Messaging Backend を通してクライアントアプリケーションにメッセージを送信したり、クライアント側の SDK で受信したメッセージを表示することができるサービスです。

バックエンドコードを書かなくてもクライアントからクロスプラットフォームにメッセージの送信が行えるため、ブラウザの Angular アプリの処理をトリガーに Android アプリへプッシュ通知を送信することが可能です。

Web アプリの Service Worker の処理は firebase-messaging-sw.js というファイル名にする必要がありますが、ひと手間加えれば Angular/PWA の Service Worker の実体である ngsw-worker.js と併用することができ、 Angular が提供する Service Worker の機能をそのまま活かすことができます。

firebase.google.com

Google Cloud

Firebase も Google が提供するクラウドサービスですが、こちらが Google 本家のクラウドサービスになります。

今回のバックエンドのメインは Firebase ですが、提供元が同じ Google というだけあって Google Cloud の各製品と容易に連携することができます。

cloud.google.com

Cloud Pub/Sub

Publisher がトピックに対してメッセージを送信し、そのトピックを購読している Subscriber が非同期でメッセージを受信する、という Pub/Sub メッセージングモデルを実現するためのマネージドサービスです。

今回のアプリでは Gmail のメール受信時や Cloud Scheduler による定期実行でそれぞれ決められたトピックにメッセージを送信し、そのトピックを購読する Subscriber が Cloud Functions の処理を実行するという流れでいくつかの関数を実行しています。

cloud.google.com

Cloud Scheduler

cron 形式で指定したスケジュールに従い、トピックへのメッセージ送信や HTTP アクセスなどの予め決められた処理を実行するサービスです。

Cloud Scheduler → Cloud Pub/Sub → Cloud Functions と各サービスを連携させることで、Cloud Functions の定期実行を実現しています。

バックエンドコードを書いたら Firebase CLI で Cloud Functions のデプロイコマンドを実行するだけで上記構成を構築できるので、 Cloud Scheduler や Cloud Pub/Sub をほとんど意識することなく非常に簡単に実装することができます。

cloud.google.com

Secret Manager

Google Cloud で使用する API キーやパスワードなどの機密性の高いデータを、一元的に保存・管理するためのストレージサービスです。

今回は Cloud Functions からアクセスする外部サービスの API キーなどを保存するために使用しています。

cloud.google.com

Cloud Source Repositories

Google Cloud でホストされるプライベート Git リポジトリです。

ソースコードのバージョン管理を行うという目的においては必要十分なサービスになっていますが、 GitHub のような Pull Request や Issue の管理といった機能は無く、イマドキの Git ホスティングサービスと比較するとやや見劣りするというのは否定できません。

多分 Google さんも、このサービスにはあまり力を入れていないんじゃないかと思います…

cloud.google.com

Gmail

Gmail API

Gmail を操作するための API で、 JavaScriptPython などの様々な言語に対応しています。

今回は Gmail でのメール送信やメール受信時の Pub/Sub のトピックへのメッセージ送信など、主にバックエンドの処理で使用しました。

developers.google.com

開発環境・開発ツール

Windows 10 Pro の WSL2 上に Docker 環境を構築し、 Node.js の Docker イメージをベースとしたコンテナ内で開発を行いました。

開発ツールは今や高機能エディタのデファクトスタンダードになりつつある Visual Studio Code で、拡張機能を必要に応じて重くならない程度に入れています。

コードフォーマッターとリンターはそれぞれ Prettier と ESLint を使用しています。

Prettier については別記事で詳しく書いていますので、そちらをご覧ください。

ai-can-fly.hateblo.jp

そのうち ESLint についても自分用としてまとめたい…

おわりに

サーバレスという言葉が出て久しいですが、バックエンドをサーバレスで構築した初めてのアプリがこの Web アプリケーションでした。

これまでは誰かにサーバの構築を依頼して、その上に自分が開発したアプリケーションをデプロイするというのが常でしたが、要件さえ合致すればインフラエンジニアにはアドバイザーとしてスポット参戦してもらうだけでも、クラウドサービスを活用すれば何とかできなくもない状態になってきています。

環境構築やアプリケーションのデプロイも用意された CLI のコマンドだけで済む場合が多く、フロントエンド・バックエンドエンジニアにとっては本当に便利な世の中になりました…

これからもクラウドを活用して、フロントエンドからバックエンド、クラウドサービスを守備範囲とするフルスタックエンジニアとしてスキルを伸ばしていこうと思います。

ということで、今回紹介した事例のような開発を依頼、またはエンジニアを探している方はご連絡お待ちしています (๑´ڡ`๑)

【Docker】初心者のための Dockerfile まとめ

docker-logo

はじめに

普段 Docker を使用して開発を行っていますが、ちょっとした開発程度であれば Docker Hub に公開されている Docker イメージをそのまま利用するだけで十分事足りています。

しかし、少し手の込んだ環境や無駄の無い環境を構築したい場合は、 Dockerfile を使用してオリジナルな Docker イメージを構築する必要があります。

以前 Docker Compose に関する記事 を書きましたが、今回はもう少し Docker に踏み込んで Dockerfile についてまとめてみました。

ai-can-fly.hateblo.jp

まとめた内容については Docker Compose 同様、初心者向けとなっています。

何故なら、僕自身が Docker 初心者だからです!!

動作環境

OS Version
Ubuntu 20.04 LTS

Windows 10 Pro の Windows Subsystem for Linux (WSL 2) を使用

Docker Products Version
Docker Desktop for Windows 4.1.1
Docker Engine 20.10.8

Dockerfile まとめ

元も子もないことを書きますが、 Dockerfile に関する詳細は Docker 公式ドキュメントの該当ページをご覧ください。

docs.docker.com

ここではよく使用する命令を中心に、 Dockerfile の書き方をまとめました。

Dockerfile

# Comments
FROM <base_image_name>[:<tag_name>]
EXPOSE <port>[/<protocol>]
ENV <key> <value>
VOLUME <volume_path>
WORKDIR <workdir_path>
COPY <src> <dest>
RUN <command>
CMD ["<command>", "<param>"]

一般的に Dockerfile のファイル名は Dockerfile (※ 拡張子無し) が使用されます。

Dockerfile に記述された各命令は、基本的に上から順に実行されていきます。

また、行頭に # を記述すると、その一行はコメントとして扱われます。

FROM (ベースイメージ)

FROM <base_image_name>[:<tag_name>]

# e.g.
FROM python:3.9

イメージ構築時のベースとなる Docker イメージとタグを指定します。

多くの場合、ここで指定したイメージ名とタグ名で Docker Hub に公開されている Docker イメージを取得し、ベースイメージとして使用します。

EXPOSE (公開用ポート番号)

EXPOSE <port>[/<protocol>]

# e.g.
EXPOSE 5000/tcp

コンテナから公開するポート番号を指定します。

プロトコルの指定は省略可能で、省略した場合は tcp がデフォルトとして設定されます。

ここでポート番号を指定しただけでは実際に公開はされないため、 docker container run 実行時に -P-p オプションを指定する必要があります。

ENV (環境変数)

ENV <key> <value>
ENV <key1>=<value1> [<key2>=<value2> ...]

# e.g.
ENV FLASK_APP flask_app

コンテナ内で有効な環境変数を指定します。

ここで指定した環境変数は、コンテナ内で有効なだけでなく、 Dockerfile 内の以降の命令においても有効です。

VOLUME (ボリューム)

VOLUME <volume_path>

# e.g.
VOLUME /flask

ボリュームをマウントするコンテナ内のディレクトリを指定します。

ここで指定したボリュームは、 docker container run-v /flask または --volume /flask と同様の動きをします。

-v フラグではホスト側のディレクトリを指定してコンテナ側へマウントすること (Bind Mounts) や、任意の名前を付けたボリュームをマウントすること (Named Volumes) ができますが、 Dockerfile の VOLUME では Docker が自動的に決定したボリューム名でマウントされます。

また、 Dockerfile の VOLUME で作成されたボリュームは、 docker container run コマンドに -rm フラグを付けてコンテナを起動した場合、コンテナの停止時にコンテナと共に削除されることに注意が必要です。

WORKDIR (作業ディレクトリ)

WORKDIR <workdir_path>

# e.g.
WORKDIR /flask

作業ディレクトリを指定します。

ここで指定するディレクトリが存在しない場合、新たにディレクトリが作成されます。

WORKDIR を指定した以降の Dockerfile 内の命令は、ここで指定した作業ディレクトリをカレントディレクトリとして実行されます。

COPY (コピー)

COPY <src> <dest>

# e.g.
COPY ./source/* /flask/

ホストのファイルやディレクトリを、コンテナ内の指定したパスへコピーします。

相対パスを指定した場合、ホスト側はビルドコンテキスト、コンテナ側は WORKDIR をカレントディレクトリとしてコピーが実行されます。

コピー元 (ホスト側) のディレクトリやファイルは複数指定、またはワイルドカード (* など) を使用した場合、コピー先 (コンテナ側) はディレクトリを指定する必要があります。

RUN (コマンド実行 (イメージ構築時))

RUN <command>

# e.g.
RUN pip install -U pip \
 && pip install -r requirements.txt

イメージ構築時に実行するコマンドを指定します。

\ (バックスラッシュ) を使用して、一つの RUN 命令を複数行にわたって記述することが可能です。

コマンドの結果はキャッシュされ、次回以降のイメージ構築時にコマンドに変更が無かった場合、コマンドは実行されずにキャッシュされた結果が使用されます。

そのため、例えば RUN apt update と一行で記述した場合、そのキャッシュが初回以降のイメージ構築時に使用されるため、古いパッケージ一覧を使用してしまう可能性があります。

キャッシュを使用せずにイメージ構築を行うには、 docker image build コマンドに --no-cache フラグを指定する必要があります。

CMD (コマンド実行 (コンテナ開始時))

CMD ["<command>", "<param>"]

# e.g.
CMD ["/bin/bash"]

コンテナ開始時に実行するコマンドを指定します。

exec 形式 (CMD ["<command>", "<param>"]) で記述した場合はシェルが実行されないため、コマンドはフルパスで記述する必要があります。

Docker CLI

作成した Dockerfile を基に Docker イメージを構築するには、 docker image build コマンドを使用します。

image

docker image <command>

Docker イメージを管理するコマンドを実行します。

build (Dockerfile から Docker イメージを構築)

docker image build [options] <build_context>
Options Description Example
--file, -f Dockerfile へのパスを指定 -f ./path/to/Dockerfile
--tag, -t 構築するイメージ名とタグを指定 -t repository/image_name:tag_name
--no-cache キャッシュを使用せずにイメージを構築 --no-cache

指定した Dockerfile とビルドコンテキストから、イメージを構築します。

Dockerfile へのパスを指定しなかった場合、ビルドコンテキストのルートディレクトリに存在する Dockerfile が利用されます。

container

docker container <command>

Docker コンテナを管理するコマンドを実行します。

run (Docker イメージから Docker コンテナを作成・開始)

docker container run [option] <image_name>[:<tag_name>] [command]
Options Description Example
--detach, -d コンテナをバックグラウンドで実行し、コンテナ ID を出力 -d
--interactive, -i アタッチされていなくても、 STDIN を受け付ける -i
--mount コンテナにマウントするボリュームやファイルシステムを指定 --mount type=bind, source=/var/log/flask, destination=/flask/logs
--name 作成するコンテナ名を指定 --name container_name
--publish, -p ホストに対して公開するコンテナのポート番号と対応させるホストのポート番号を指定 -p 5000:5000
--publish-all, -P Dockerfile の EXPOSE 命令で指定されたポート番号を公開し、ホストのランダムポートへ対応させる -P
--rm コンテナ停止時にコンテナを削除 --rm
--tty, -t コンテナに疑似 TTY を割り当て -t
--volume, -v コンテナにマウントするボリュームやファイルシステムを指定 -v /var/log/flask:/flask/logs

指定した Docker イメージから、 Docker コンテナを作成し開始します。

[command] を指定した場合、 Docker イメージの CMD 命令が上書きされ、開始された Docker コンテナ内で指定したコマンドが実行されます。

-d フラグを指定しなかった場合はフォアグラウンドで実行され、 Ctrl + C でコンテナを停止できます。

おわりに

以前書いた Docker Compose の記事で Dockerfile は Docker Compose ほど難しくない的なことを書きましたが、むしろ Dockerfile の方が Docker イメージを無駄なく効率良く作成するために色々と考える必要があり、非常に奥が深いものであると再認識しました…

とはいえ、開発で使用する限りにおいては多少ざっくりと書いても問題になることは少ないと思われるので、本当にシビアに書く必要があるのは本番環境で運用する場合などに限られるのではないかと思います。

仕事関係、特に弊社内ではまだ Docker に関する知見が少なく Docker 人口自体が少ない上、こういうのはインフラエンジニアが担当することが多いこともあって、なかなか実践的な知識を身につける機会がありません…

開発で使用する Dockerfile オレオレベストプラクティスはまだ見付けられていないので、引続き個人で色々と試しながら Docker の知識を深めていきたいと思います。

みんな、もっと Docker 使おう (꒪ཫ꒪; )

【TypeScript】そろそろ TSDoc を始めてみる

TSDoc

はじめに

それなりに TypeScript を書いてきて、ぼちぼち初心者という枠を出るか出ないかというところまできたので (とは言ってもまだまだ初級者) 、ちょうど仕事でドキュメンテーションしっかりしようかっていう流れもあり、今まで雰囲気で書いてきたコメント (アノテーション) を見直すタイミングがやってきました。

JavaScript のコメント (アノテーション) は JSDoc がデファクトスタンダードになっていて、 TypeScript は今のところコレというものは決まっていないようでしたが、今回は JSDoc の TypeScript 版である TSDoc をマークアップ言語として選択しました。

TSDoc は MicrosoftOSS として公開しており、おそらく今後は JSDoc のように TypeScript アノテーションデファクトスタンダードになるであろうと (個人的に) 思ったからです。

2021 年 8 月現在、あまり日本語で書かれた記事を見付けられなかったため、 TSDoc の公式ドキュメント からの引用を中心に、とりあえずこれが分かればなんとか書けそうという内容をまとめてみました。

TL;DR

  • TSDoc は TypeScript のコメント (アノテーション) を標準化するための提案 (今のところ) だよ
  • JSDoc と違って、公式からドキュメント出力してくれるパッケージとかは出てないよ
  • 細かいことは置いといて、使用例だけ見たいって人は ここ を見てね

TSDoc とは

tsdoc.org

前述した通り、 JavaScript のコメント (アノテーション) を書くマークアップ言語のデファクトスタンダードである JSDoc の TypeScript 版として、 Microsoft から OSS として公開されている TypeScript アノテーション用のマークアップ言語です。

JSDoc はデファクトスタンダードとされつつも、どうやら標準化は行われていないようで (本当?) 、 JSDoc で書かれたコメントからドキュメントを出力できる同名の npm パッケージ (jsdoc) の実装がある意味本体となっているようです。 (本当??)

TSDoc はルール (構文) の標準化を目的としており、 TSDoc で書かれたコメントのパース (解析) を行うエンジンコンポーネント (npm パッケージ: @microsoft/tsdoc) や、 ESLint 用のプラグイン (npm パッケージ: eslint-plugin-tsdoc) なども公開されています。

注意点として、 JSDoc と違い、 npm パッケージの @microsoft/tsdoc はパーサーとしてコードや他のツールなどに組み込まれる前提のエンジンコンポーネントなので、 TSDoc で書かれたコメントをドキュメントとして出力したい場合は、別途ツールやパッケージを使用する必要があります。

TSDoc 構文

TSDoc では構文として定められたタグを使用し、アノテーションを追加したい変数や関数などの直前にコメントを記述します。

@microsoft/tsdoc のパーサーに認識してもらうためには、アノテーション/** で始める必要があります。

export class TSDocSyntaxExample() {
  /**
   * 要約
   *
   * @remarks
   * 詳細な説明
   *
   * @param name - パラメータの説明
   * @returns 戻り値の説明
   *
   * @beta (← 装飾タグ)
   */
  public helloMessage(name: string): string {
    return 'Hello ' + name + ' !!';
  }
}

タグ

タグは @ から始まり、キャメルケース (camelCase) で記述します。

TSDoc で定められているタグは、後述する三種類のいずれかに分類されます。

また、各タグは TSDoc が互換性のある各種ドキュメントツールから使用される際に、サポートが期待されるレベルに応じて、同じく後述する三つの標準化グループに分類されます。

タグの種類 (Tag kinds)

tsdoc.org

Block tags (ブロックタグ)

一部のタグを除き、ブロックタグは先頭行にタグ、そして次の行からタグの内容 (タグコンテンツ) を記述します。

ブロックタグから次のブロックタグ、またはモディファイタグまでがタグコンテンツと解釈されます。

ブロックタグに続けてタグコンテンツを一行で書いたり、ブロックタグの次行から複数行に渡ってタグコンテンツを書いたりします。

/**
 * This is the special summary section.
 *
 * @remarks
 * This is a standalone block.
 *
 * @example Logging a warning
 * ```ts
 * logger.warn('Something happened');
 * ```
 *
 * @example Logging an error
 * ```ts
 * logger.error('Something happened');
 * ```
 */

Modifier tags (モディファイタグ)

モディファイタグは通常、コメント (アノテーション) の最下部一行にタグのみを記述し、アノテーション対象を装飾します。

一行に複数のモディファイタグを記述することもできます。

仮にモディファイタグにタグコンテンツが記載されていた場合、パーサーは無視 (破棄) をするか、互換性が向上する場合*1に限り、直前のブロックタグに関連付けられます。

/**
 * This is the special summary section.
 *
 * @remarks
 * This is a standalone block.
 *
 * @public @sealed
 */

Inline tags (インラインタグ)

インラインタグは、ブロックタグのタグコンテンツ内の要素として、常に中括弧 ({}) に囲んで記述します。

class Book {
  /**
   * Writes the book information into a JSON file.
   *
   * @remarks
   * This method saves the book information to a JSON file conforming to the standardized
   * {@link http://example.com/ | Example Book Interchange Format}.
   */
  public writeFile(options?: IWriteFileOptions): void {
    . . .
  }

  /**
   * {@inheritDoc Book.writeFile}
   * @deprecated Use {@link Book.writeFile} instead.
   */
  public save(): void {
    . . .
  }
}

標準化グループ (Standardization groups)

tsdoc.org

Core (コアグループ)

Enum value: Standardization.Core

Core に分類されるタグは必須タグとして標準化され、すべてのツールがこれらをサポートすることが期待されています。

TSDoc パーサ (@microsoft/tsdoc) はこれらのタグへアクセスするための専用 API を提供します。

Extended (拡張グループ)

Enum value: Standardization.Extended

Extended に分類されるタグはオプションとされ、ツールによってサポートする場合としない場合がありますが、サポートする場合は TSDoc の構文と定義された役割に準拠する必要があります。

Discretionary (任意グループ)

Enum value: Standardization.Discretionary

Discretionary に分類されるタグも Extended 同様にオプションとなり、構文は TSDoc によって指定されていますが、意味・役割は実装依存 (ツールにより異なる) になります。

ただ、同じタグは異なるツールであっても、おそらく似た意味で使用されていると予想されています。 (が、やはり必ず同義であることは保証されていません。)

タグリファレンス

TSDoc で定義されているタグを、標準化グループ毎、かつ、タグ種別ごとにまとめました。

各タグで例として記載しているコードは、 TSDoc 公式ドキュメント からの引用がベースとなりますので、安心して参考にしてください。

ただし、いつものことですが、各タグの解説は TSDoc 公式ドキュメント の僕の超訳でお送りします。

解説には、サードパーティ製の各ドキュメントツールから各タグがどのような内容であることを期待されているのか、また、どのように解釈されるのかも、書いたり書かなかったりしています。

Core (コアグループ)

Block tags (ブロックタグ)

@deprecated (非推奨)

TSDoc: @deprecated

/**
 * The base class for controls that can be rendered.
 *
 * @deprecated Use the new {@link Control} base class instead.
 */
export class VisualControl {
  . . .
}

API が非推奨 (サポート対象外) となり、将来的に削除される可能性があることを表します。

@deprecated タグの後に、推奨される代替手段を記述します。

アノテーションの対象が class の場合、クラス自体が非推奨となるため、クラス内のプロパティやメソッドもすべて非推奨となります。

@param (引数)

TSDoc: @param

/**
 * Returns the average of two numbers.
 *
 * @remarks
 * This method is part of the {@link core-library#Statistics | Statistics subsystem}.
 *
 * @param x - The first input number
 * @param y - The second input number
 * @returns The arithmetic mean of `x` and `y`
 *
 * @beta
 */
function getAverage(x: number, y: number): number {
  return (x + y) / 2.0;
}

アノテーションの対象である関数の引数を表します。

@param タグの後に、 パラメータ名 - 説明 の形式で記述します。

@privateRemarks (非公開の備考)

TSDoc: @privateRemarks

/**
 * The summary section should be brief. On a documentation web site,
 * it will be shown on a page that lists summaries for many different
 * API items.  On a detail page for a single item, the summary will be
 * shown followed by the remarks section (if any).
 *
 * @remarks
 *
 * The main documentation for an API item is separated into a brief
 * "summary" section optionally followed by an `@remarks` block containing
 * additional details.
 *
 * @privateRemarks
 *
 * The `@privateRemarks` tag starts a block of additional commentary that is not meant
 * for an external audience.  A documentation tool must omit this content from an
 * API reference web site.  It should also be omitted when generating a normalized *.d.ts file.
 */

一版ユーザ向けではない追加のドキュメントであることを表します。

@remarks (備考)

TSDoc: @remarks

/**
 * The summary section should be brief. On a documentation web site,
 * it will be shown on a page that lists summaries for many different
 * API items.  On a detail page for a single item, the summary will be
 * shown followed by the remarks section (if any).
 *
 * @remarks
 *
 * The main documentation for an API item is separated into a brief
 * "summary" section optionally followed by an `@remarks` block containing
 * additional details.
 *
 * @privateRemarks
 *
 * The `@privateRemarks` tag starts a block of additional commentary that is not meant
 * for an external audience.  A documentation tool must omit this content from an
 * API reference web site.  It should also be omitted when generating a normalized *.d.ts file.
 */

API のメインドキュメントは要約を記載するにとどめ、詳細な説明は @remarks セクションに記載します。

アノテーションの先頭から @remarks タグまでがメインドキュメントの要約セクションとなり、 @remarks タグ以降が詳細な説明を記述する備考セクションであることを表します。

@returns (戻り値)

TSDoc: @returns

/**
 * Returns the average of two numbers.
 *
 * @remarks
 * This method is part of the {@link core-library#Statistics | Statistics subsystem}.
 *
 * @param x - The first input number
 * @param y - The second input number
 * @returns The arithmetic mean of `x` and `y`
 *
 * @beta
 */
function getAverage(x: number, y: number): number {
  return (x + y) / 2.0;
}

アノテーションの対象である関数の戻り値を表します。

@typeParam (型引数)

TSDoc: @typeParam

/**
 * Alias for array
 *
 * @typeParam T - Type of objects the list contains
 */
type List<T> = Array<T>;

/**
 * Wrapper for an HTTP Response
 * @typeParam B - Response body
 * @typeParam <H> - Headers
 */
interface HttpResponse<B, H> {
    body: B;
    headers: H;
    statusCode: number;
}

アノテーションの対象であるジェネリックの型引数を表します。

@typeParam タグの後に、 パラメータ名 - 説明 の形式で記述します。

Modifier tags (モディファイタグ)

@packageDocumentation

TSDoc: @packageDocumentation

// Copyright (c) Example Company. All rights reserved. Licensed under the MIT license.

/**
 * A library for building widgets.
 *
 * @remarks
 * The `widget-lib` defines the {@link IWidget} interface and {@link Widget} class,
 * which are used to build widgets.
 *
 * @packageDocumentation
 */

/**
 * Interface implemented by all widgets.
 * @public
 */
export interface IWidget {
  /**
   * Draws the widget on the screen.
   */
  render(): void;
}

アノテーションが npm パッケージ全体に対して記述するコメントであることを示します。

@packageDocumentation タグは個々の API に対してではなく、パッケージのエントリーポイントにあたるファイルの先頭のコメントに対してのみ使用することができます。

Inline tags (インラインタグ)

@label (ラベル)

TSDoc: @label

export interface Interface {
  /**
   * Shortest name:  {@link InterfaceL1.(:STRING_INDEXER)}
   * Full name:      {@link (InterfaceL1:interface).(:STRING_INDEXER)}
   *
   * {@label STRING_INDEXER}
   */
  [key: string]: number;

  /**
   * Shortest name:  {@link InterfaceL1.(:FUNCTOR)}
   * Full name:      {@link (InterfaceL1:interface).(:FUNCTOR)}
   *
   * {@label FUNCTOR}
   */
  (source: string, subString: string): boolean;

  /**
   * Shortest name:  {@link InterfaceL1.(:CONSTRUCTOR)}
   * Full name:      {@link (InterfaceL1:interface).(:CONSTRUCTOR)}
   *
   * {@label CONSTRUCTOR}
   */
  new (hour: number, minute: number);
}

@label タグは、変数やフィールド、プロパティなどの宣言にラベルを付けるために使用されます。

ラベル付けされた宣言は、 @link タグから参照することができます。

TSDoc: @link

/**
 * Let's learn about the `{@link}` tag.
 *
 * @remarks
 *
 * Links can point to a URL: {@link https://github.com/microsoft/tsdoc}
 *
 * Links can point to an API item: {@link Button}
 *
 * You can optionally include custom link text: {@link Button | the Button class}
 *
 * Suppose the `Button` class is part of an external package.  
 * In that case, we can include the package name when referring to it:
 *
 * {@link my-control-library#Button | the Button class}
 *
 * The package name can include an NPM scope and import path:
 *
 * {@link @microsoft/my-control-library/lib/Button#Button | the Button class}
 *
 * We can refer to a member of the class:
 *
 * {@link controls.Button.render | the render() method}
 *
 * If a static and instance member have the same name, we can use a selector to distinguish them:
 *
 * {@link controls.Button.(render:instance) | the render() method}
 *
 * {@link controls.Button.(render:static) | the render() static member}
 *
 * This is also how we refer to the class's constructor:
 *
 * {@link controls.(Button:constructor) | the class constructor}
 */

API のメンバや @label タグ、インターネット上の URL へのハイパーリンクを作成するために使用します。

実際にハイパーリンクを生成するためにはドキュメント生成ツールなどが必要ですが、 @link タグ自体は他リソースへの参照を表すことができます。

Extended (拡張グループ)

Block tags (ブロックタグ)

@decorator (デコレータ)

TSDoc: @decorator

class Book {
  /**
   * The title of the book.
   * @decorator `@jsonSerialized`
   * @decorator `@jsonFormat(JsonFormats.Url)`
   */
  @jsonSerialized
  @jsonFormat(JsonFormats.Url)
  public website: string;
}

API にデコレータが使用されていることを表します。

これは TypeScript のコンパイラが出力する型定義ファイル (*.d.ts) にデコレータが含まれないため、その回避策として用意されたタグになります。

@defaultValue (既定値)

TSDoc: @defaultValue

enum WarningStyle {
  DialogBox,
  StatusMessage
}

interface IWarningOptions {
  /**
   * Determines how the warning will be displayed.
   *
   * @remarks
   * See {@link WarningStyle| the WarningStyle enum} for more details.
   *
   * @defaultValue `WarningStyle.DialogBox`
   */
  warningStyle: WarningStyle;

  /**
   * Whether the warning can interrupt a user's current activity.
   * @defaultValue
   * The default is `true` unless
   *  `WarningStyle.StatusMessage` was requested.
   */
  cancellable?: boolean;
}

@defaultValue タグは class または interface のメンバーであるフィールドかプロパティに対してのみ使用することが可能で、明示的に値が割り当てられていない場合の既定値を表します。

書き方は二通りあり、既定値を @defaultValue タグの後に記載するか、次の行からタグコンテンツとして既定値の説明文を記述します。

@example (使用例)

TSDoc: @example

/**
 * Adds two numbers together.
 * @example
 * Here's a simple example:
 * ```
 * // Prints "2":
 * console.log(add(1,1));
 * ```
 * @example
 * Here's an example with negative numbers:
 * ```
 * // Prints "0":
 * console.log(add(1,-1));
 * ```
 */
export function add(x: number, y: number): number {
}

/**
 * Parses a JSON file.
 *
 * @param path - Full path to the file.
 * @returns An object containing the JSON data.
 *
 * @example Parsing a basic JSON file
 *
 * # Contents of `file.json`
 * ```json
 * {
 *   "exampleItem": "text"
 * }
 * ```
 *
 * # Usage
 * ```ts
 * const result = parseFile("file.json");
 * ```
 *
 * # Result
 * ```ts
 * {
 *   exampleItem: 'text',
 * }
 * ```
 */
export function parseFile(path: string): object {
}

API の使用方法を表します。

@example タグと同じ行の後続テキストはその例のタイトルとして解釈され、複数の @example タグを記載した場合は、各使用例をインデックスで表すことができます。

上の例のタイトルは ExampleExample2 、下の例のタイトルは Example: Parsing a basic JSON file になります。

また、タグコンテンツには Markdown でコードサンプルを記述することができます。

@see (参照先)

TSDoc: @see

/**
 * Parses a string containing a Uniform Resource Locator (URL).
 * @see {@link ParsedUrl} for the returned data structure
 * @see {@link https://tools.ietf.org/html/rfc1738|RFC 1738} for syntax
 * @see your developer SDK for code samples
 * @param url - the string to be parsed
 * @returns the parsed result
 */
function parseURL(url: string): ParsedUrl;

API に関連する他の API やリソースへの参照リストを作成するために使用します。

@see タグと同じ行の後に、参照先のリンクや説明文を記述します。

@throws (例外)

TSDoc: @throws

/**
 * Retrieves metadata about a book from the catalog.
 *
 * @param isbnCode - the ISBN number for the book
 * @returns the retrieved book object
 *
 * @throws {@link IsbnSyntaxError}
 * This exception is thrown if the input is not a valid ISBN number.
 *
 * @throws {@link book-lib#BookNotFoundError}
 * Thrown if the ISBN number is valid, but no such book exists in the catalog.
 *
 * @public
 */
function fetchBookByIsbn(isbnCode: string): Book;

アノテーションの対象である関数やプロパティからスローされる例外の型を表します。

スローされる可能性のある例外が複数存在する場合、それぞれ個別に @throws タグを使用する必要があります。

@throws タグと同じ行の後に例外の名前を記述しますが、必須ではありません。

Modifier tags (モディファイタグ)

@eventProperty (イベントプロパティ)

TSDoc: @eventProperty

class MyClass {
  /**
    * This event is fired whenever the application navigates to a new page.
    * @eventProperty
    */
  public readonly navigatedEvent: FrameworkEvent<NavigatedEventArgs>;
}

classinterface のプロパティが、アタッチ可能なイベントオブジェクトを返すことを表します。

戻り値のイベントオブジェクトには、 addHandler()removeHandler() といったイベントハンドラを操作するメソッドを持つクラスであることが期待されます。

@override (再定義・オーバーライド)

TSDoc: @override

class Base {
  /** @virtual */
  public render(): void { // サブクラスによって再定義される可能性がある
  }

  /** @sealed */
  public initialize(): void { // サブクラスで再定義してはいけない
  }
}

class Child extends Base {
  /** @override */
  public render(): void; // 基本クラスの定義を再定義している
}

アノテーションの対象がメンバー関数やプロパティの場合、この定義が基本クラスから継承された定義をオーバーライド (再定義) していることを表します。

通常、再定義される可能性のある基本クラスの定義には @virtual タグを記述します。

@readonly (読み取り専用)

TSDoc: @readonly

export class Book {
  /**
   * Technically property has a setter,
   * but for documentation purposes it should be presented as readonly.
   * @readonly
   */
  public get title(): string {
    return this._title;
  }

  public set title(value: string) {
    throw new Error('This property is read-only!');
  }
}

TypeScript の構文として読み取り専用であるかどうかに関わらず、仕様として API が読み取り専用であることを表します。

上の例では、 Setter 関数でプロパティに値を割り当てていないため、 Book.title が読み取り専用であることを示しています。

@virtual (再定義の可能性有)

TSDoc: @virtual

class Base {
  /** @virtual */
  public render(): void { // サブクラスによって再定義される可能性がある
  }

  /** @sealed */
  public initialize(): void { // サブクラスで再定義してはいけない
  }
}

class Child extends Base {
  /** @override */
  public render(): void; // 基本クラスの定義を再定義している
}

アノテーションの対象がメンバー関数やプロパティの場合、この定義をサブクラスがオーバーライド (再定義) する可能性があることを表します。

@sealed (継承・再定義不可)

TSDoc: @sealed

class Base {
  /** @virtual */
  public render(): void { // サブクラスによって再定義される可能性がある
  }

  /** @sealed */
  public initialize(): void { // サブクラスで再定義してはいけない
  }
}

class Child extends Base {
  /** @override */
  public render(): void; // 基本クラスの定義を再定義している
}

アノテーションの対象がクラスの場合は継承不可、メンバー関数やプロパティの場合はサブクラスがオーバーライド (再定義) してはならないことを表します。

Inline tags (インラインタグ)

@inheritDoc (ドキュメントの引用)

TSDoc: @inheritDoc

import { Serializer } from 'example-library';

/**
 * An interface describing a widget.
 * @public
 */
export interface IWidget {
  /**
   * Draws the widget on the display surface.
   * @param x - the X position of the widget
   * @param y - the Y position of the widget
   */
  public draw(x: number, y: number): void;
}

/** @public */
export class Button implements IWidget {
  /** {@inheritDoc IWidget.draw} */
  public draw(x: number, y: number): void {
    . . .
  }

  /**
   * {@inheritDoc example-library#Serializer.writeFile}
   * @deprecated Use {@link example-library#Serializer.writeFile} instead.
   */
  public save(): void {
    . . .
  }
}

対象の API に対し、他のクラスや npm パッケージの APIアノテーションを引用 (コピー) していることを表します。

コピー対象のタグ (コメント) は、

  • 要約セクション
  • @remark
  • @params
  • @typeParam
  • @return

のみとなり、コピー対象外のタグについては @inheritDoc タグ以降に記述する必要があります。

@inheritDoc タグを記述した場合、そのアノテーションにはメインコメントである要約や @remark タグは記述できません。

Discretionary (任意グループ)

Modifier tags (モディファイタグ)

@alpha (アルファ版・未リリース)

TSDoc: @alpha

/**
 * Represents a book in the catalog.
 * @public
 */
export class Book { // Book クラスはリリース済み
  /**
   * The title of the book.
   * @alpha
   */
  public get title(): string; // Book.title は未リリースのアルファ版

  /**
   * The author of the book.
   */
  public get author(): string; // Book.author も Book を継承してリリース済み
};

API がアルファ版 (未リリース) であることを表します。

API を使用することは可能ですが、あくまでもまだリリースはしていないことを示す場合に使用します。

@beta (ベータ版・実験的リリース)

TSDoc: @beta

/**
 * Represents a book in the catalog.
 * @public
 */
export class Book { // Book クラスはリリース済み
  /**
   * The title of the book.
   * @beta
   */
  public get title(): string; // Book.title は実験的リリースのベータ版

  /**
   * The author of the book.
   */
  public get author(): string; // Book.author も Book を継承してリリース済み
};

API がベータ版 (実験的リリース) であることを表します。

目的がフィードバック収集の場合など、リリースはされたものの、まだ運用には適さないことを示す際に使用します。

@experimental (実験段階)

TSDoc: @experimental

/**
 * Represents a book in the catalog.
 * @public
 */
export class Book { // Book クラスはリリース済み
  /**
   * The title of the book.
   * @experimental
   */
  public get title(): string; // Book.title は実験段階

  /**
   * The author of the book.
   */
  public get author(): string; // Book.author も Book を継承してリリース済み
};

@alpha@beta と同様に、未リリースの実験段階であることを表します。

リリースステージとしては、 @experimental@alpha@beta と進みます。 (と思われます。)

@internal (内部用)

TSDoc: @internal

/**
 * Represents a book in the catalog.
 * @public
 */
export class Book { // Book クラスはリリース済み
  /**
   * The title of the book.
   * @internal
   */
  public get _title(): string; // Book._title は内部用

  /**
   * The author of the book.
   */
  public get author(): string; // Book.author も Book を継承してリリース済み
};

API が外部から使用される予定が無いことを表します。

publicprivate などのアクセス修飾子による違いではなく、そもそも API 自体が使用されないということを示しています。

@public (公開版・リリース済)

TSDoc: @public

/**
 * Represents a book in the catalog.
 * @public
 */
export class Book { // Book クラスはリリース済み
  /**
   * The title of the book.
   * @internal
   */
  public get _title(): string; // Book._title は内部用

  /**
   * The author of the book.
   */
  public get author(): string; // Book.author も Book を継承してリリース済み
};

API が公開されているリリース版であることを表します。

使用例

ここまで長々と書きましたが、結局どう書いたらいいの?ということで、 Angular のコードにアノテーションを付けてみました。 (コードは適当なので、そこへのつっこみは無しの方向で…)

簡易なアノテーションから詳細なアノテーションまでいくつかのパターンで記載していますので、この中からプロジェクトやチームに合った形を見付けて参考にしていただければと思います。

これが正解というわけではないので、とりあえずこんな感じでスタートして、徐々にチーム内でブラッシュアップしていけばいいんじゃないでしょうか。

正直、正解はよく分からん…

クラス (Class)

/**
 * ユーザ情報クラスコンポーネント
 *
 * @remarks
 * ユーザ情報を表示する画面を構成するコンポーネントです。
 *
 * @decorator `@Component()`
 *
 * @public
 */
@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  styleUrls: ['./user.component.scss']
})
export class UserComponent implements OnInit {
  /**
   * ユーザ ID
   *
   * @remarks
   * ユーザ ID は親コンポーネントから渡されます。
   *
   * @decorator `@Input()`
   */
  @Input()
  userId: string = '';

  /**
   * ユーザ名
   * 
   * @defaultValue `'unknown'`
   */
  userName: string;

  constructor(
    private usrSvc: userService
  ) {
    this.userName = 'unknown';
  }

  /** @override */
  ngOnInit(): void {
    this.showUserName(this.userId);
  }

  /**
   * ユーザ名を表示する。
   *
   * @remarks
   * パラメータとして与えられたユーザ ID からユーザ名を取得し、
   * ユーザ名プロパティへ値をセットします。
   * 
   * @privateRemarks
   * ユーザ情報の型 {@link (:USER) | User model}
   *
   * @param id - ユーザ ID
   *
   * @public
   */
  private showUserName(id: string): void {
    if (id === '') {
      return;
    }

    this.usrSvc.getName<User>(id).subscribe(
      (user: User): void => {
        this.userName = user.name;
      }
    );
  }
}

インターフェース (Interface)

/**
 * ユーザ情報
 *
 * {@label USER}
 *
 * @public
 */
export interface User {
  /** ユーザ ID */
  id: string;
  /** ユーザ名 */
  name: string;
}

ジェネリック (Generics)

@Injectable({
  providedIn: 'root'
})
export class UserService {
  constructor(
    private http: HttpClient
  ) {}

  /**
   * ユーザ名を取得する。
   *
   * @remarks
   * パラメータとして与えられたユーザ ID からユーザ名を含む
   * ユーザ情報を取得し、戻り値として返します。
   * 
   * @privateRemarks
   * ユーザ名だけでなく、ユーザ情報すべてを取得している。
   *
   * @typeParam T - ユーザ情報
   * @param id - ユーザ ID
   * @returns ユーザ名を含むユーザ情報
   *
   * @beta @sealed
   */
  getName<T>(id: string): Observable<T> {
    const options = new HttpParams().set('id', id);
    return this.http.get('/path/to/api/', options);
  }
}

おわりに

今回まとめるにあたって一通りすべてのタグに目を通しましたが、アノテーションにこれらのタグをフルスペックで記述したら、かなり充実するだろうなーと思いました。

プロジェクト要件にプログラム設計書の作成・納品などが含まれている場合、いちいちドキュメントを作成するくらいなら、 TSDoc でしっかりアノテーションを書いて、ツールでドキュメント出力をした方が確実で早いし簡単ですね。

良い意味で「ソースコードが仕様書です!」と言えるんじゃないでしょうか。

今まで悪い意味でしか使ったことが無いですが…

あとは JSDoc のように、 TSDoc 公式からドキュメント出力をしてくれる npm パッケージが公開されたら大変ありがたいのですが、 TSDoc のロードマップ にはそんなこと一切書かれていないし、 @microsoft/tsdoc はあくまでも TSDoc のパーサーという位置付けなんでしょうね。。

TypeDoc とか使えばいいんかな… _(-ω-`_)⌒)_

*1:どういう場合かよく分かりません…