git diffにまだ一度もコミットされていないファイルを含めるにはgit add --intent-to-add

まだ一度もコミットしていないファイルを git diff で表示したい場合は、git add--intent-to-add オプションを使うと、コミットせずとも git diff に表示できます。

$ echo 'Hello' > example.txt
$ git add --intent-to-add .
$ git diff
diff --git a/example.txt b/example.txt
new file mode 100644
index 0000000..e965047
--- /dev/null
+++ b/example.txt
@@ -0,0 +1 @@
+Hello

CIでgo generateしてみて差分があるかチェックする仕組みを作りたかったので、addしていないファイルも含めてdiffに出す方法はないかと探してみたところ、 --intent-to-add オプションを見つけました。

すでにGit管理下にあるファイルであれば、差分を確認したい場合は単に git diff するだけでいいのですが、コミットされていないファイルは単にdiffするだけでは表示されないので、そのためのオプションとして --intent-to-add があるようです。

ちなみに、 --intent-to-add は空のファイルを add するようで、それによって git diff に差分が表示されるようになるようです*1

Goで手軽にコード生成したいときはgomplateを使うと楽

仕事でGo言語をよく書くのですが、Goは言語機能が多くはないためボイラープレートやコピペが増えがちです。

特に他の言語でいうところのgenericsがあれば解決するのに、と思うことがよくあるのですが、現在のGoにはそのような機能は存在しない*1ため、go generate を活用したコード生成で代用することが多いと思います。

一方で、目的のコードを生成できるツールが存在しない場合、ツールをゼロから自分で実装するのは手間なので、本来はコード生成したい場合でも、実際にはコピペで済ませてしまうことがよくあります。

何か良い手はないかと探していたのですが、gomplateを使うと手軽に目的を達成できたので紹介します。

gomplate とは

gomplateはGoで書かれたテンプレートレンダラーで、さまざまなデータソースからデータの入力を受け付け、それをGoのtemplateにレンダリングするコマンドラインツールです。

例えば以下のようにしてテンプレートをレンダリングできます。

$ gomplate -i 'Hello, {{ .Env.USER }}'
Hello, maku693

gomplateではGoのtemplateの標準的な機能に加えて、文字列を加工する関数や、YAMLや環境変数などをデータソースとして受け取る仕組みが提供されており、コード生成を含むさまざまな場面で活用できます。

例えば、enum風のconstを定義する時に使えます。

Goにはいわゆるenumの機能がないため、似たようなことを実現するには typeconst を組み合わせて実現することが多いと思います。

package main

import (
	"fmt"
	"strconv"
)

type Animal int

const (
	Dog Animal = iota
	Cat
	Bird
)

func (a Animal) Validate() error {
	switch a {
	case Dog,
		Cat,
		Bird:
		return nil
	}
	return fmt.Errorf("invalid animal: %d", a)
}

func (a Animal) String() string {
	switch a {
	case Dog:
		return "Dog"
	case Cat:
		return "Cat"
	case Bird:
		return "Bird"
	}
	return "%!Animal(" + strconv.Itoa(int(a)) + ")"
}

これくらいなら手で書けそうですが、enumが欲しくなるたびにこれらを毎回定義しなければならないとなると面倒です。

また、String() メソッドだけならgolang.org/x/tools/cmd/stringerを使えば十分ですが、ほかのメソッドも必要だったり、例示した以外の関数やメソッドが各enumに対して必要になったとき、それぞれの型に対していちいち実装を追加するのは手間です。

これらの問題を解消するには、ゼロからコード生成ツールを作ってもいいのですが、gomplateを使うと、テンプレートとデータソースを用意するだけで目的が達成できます。

まず、enumを定義するためのテンプレートを用意します。

{{- $source := datasource "source" -}}
// Code generated by go generate; DO NOT EDIT.
package {{ $source.package }}

import (
	"fmt"
	"strconv"
)

type {{ $source.type }} int

const (
{{- range $i, $value := $source.values }}
	{{ $value }} {{ $source.type }} = {{$i}}
{{- end }}
)

func (v {{$source.type}}) Validate() error {
	switch v {
{{- range $value := $source.values }}
	case {{ $value }}:
		return nil
{{- end }}
	}
	return fmt.Errorf("invalid {{ $source.type | strings.ToLower }}: %d", v)
}

func (v {{$source.type}}) String() string {
	switch v {
{{- range $value := $source.values }}
	case {{ $value }}:
		return "{{ $value }}"
{{- end }}
	}
	return "%!{{$source.type}}(" + strconv.Itoa(int(v)) + ")"
}

次に、このテンプレートに渡すデータソースを用意します。今回はYAMLを使います。

package: main
type: Animal
values:
  - Dog
  - Cat
  - Bird

最後に、これらをgomplateを使ってレンダリングするファイルを用意し、go generate コマンドでレンダリングします。

package main

//go:generate go run github.com/hairyhenderson/gomplate/v3/cmd/gomplate --datasource source=animal.yml --file enum.go.tmpl --out animal_gen.go
$ go generate .

これで以下のようなファイルが生成されます。

// Code generated by go generate; DO NOT EDIT.
package main

import (
	"fmt"
	"strconv"
)

type Animal int

const (
	Dog Animal = 0
	Cat Animal = 1
	Bird Animal = 2
)

func (v Animal) Validate() error {
	switch v {
	case Dog:
		return nil
	case Cat:
		return nil
	case Bird:
		return nil
	}
	return fmt.Errorf("invalid animal: %d", v)
}

func (v Animal) String() string {
	switch v {
	case Dog:
		return "Dog"
	case Cat:
		return "Cat"
	case Bird:
		return "Bird"
	}
	return "%!Animal(" + strconv.Itoa(int(v)) + ")"
}

最初に示したコードとは若干異なりますが、実用上は変わりありません。

他のenumを生成したければ、あらたなYAMLを用意して、go:generateコメントを追加するだけですし、実装を追加したい場合は、テンプレートを変更して再度コードを生成しなおすだけなので簡単です。

この例以外にも、テンプレートとデータソースを工夫すれば、structの特定のフィールドをキーにしてソートする関数を生成したり、あるstructのsliceを別のstructのsliceに詰め替える関数を生成したりもできます。

gomplateにはGoのコード生成専用の高度な機能は存在しないので、リフレクションを使いたいなど要件が複雑な場合は自前でコード生成ツールを書いた方がよいかもしれませんが、貧者のgenericsとしてはこれくらいでも十分なのではないでしょうか。

この記事で紹介したコードは以下のリポジトリにも置いてあります。

github.com

*1:2021年3月現在、2022年初頭リリース予定のGo1.18での追加が予定されています。https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md

Node.jsアプリケーションのDockerイメージのサイズを削減する方法について調べた

Next.jsアプリケーションのDockerイメージのサイズが大きくて困っていたので調べていたところ、Next.jsの公式ドキュメントにDocker Imageというセクションがあり、おすすめ設定が記載されているのを見つけました。以前はここまで詳細な記述はなかったのですが、つい10日ほど前に追加されたようです。

ドキュメントには一番おすすめの方法だけ書かれているのですが、もともと「What is the best way to use NextJS with docker? · Discussion #16995 · vercel/next.js · GitHub」というdiscussionがあり、ドキュメントの記述はここでの議論が元になっているようです。Discussionではいくつか例が示されているのですが、それぞれさまざまな最適化テクニックが利用されており、どれくらいの容量になるか気になったので、Dockerfileを書いて調べてみました。

検証に使ったコードはこちらに置いてあります。

github.com

なお、各テクニックはNext.jsに限らずNode.jsのアプリケーション一般で通用するものだと思います。


まずはベースラインとして最適化なしのDockerfileです。

FROM node

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]


デフォルトのnodeイメージはDebianベースなので元からサイズが大きいです。イメージのサイズを小さくしたい場合は、可能であればまずはAlpine Linuxベースのnode:alpineに乗り換えるのがいいでしょう。

(追記)Alpineでは標準Cライブラリとしてglibcではなくmusl-libcが使われており、Node.jsでもネイティブモジュールを使っている場合に互換性の問題が起きることがあります。2021年時点では、alpineではなく、glibcが採用されておりかつサイズが小さいdistrolessやslimイメージを使うのがおすすめです(ブックマークコメントでの指摘ありがとうございます)。

blog.inductor.me

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock /usr/src/app
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

yarn installはデフォルトではdevDependenciesも含めて全ての依存パッケージをインストールします。アプリケーション実行用のイメージにLinterやテストフレームワークなどの開発用ツールを含める必要はないので、--productionオプションを利用して、devDependenciesをインストールしないようにすると、イメージのサイズを小さくできます。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

さらに、Yarnのダウンロードキャッシュを削除すると、その分イメージのサイズを小さくできます。先程のdiscussionでは二つ方法が紹介されていました。

一つはyarn cache cleanコマンドを使う方法です。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile \
  && yarn cache clean

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]

もう一つは、キャッシュディレクトリを/dev/shm以下にする方法です。/dev/shmはLinuxにおけるRAMディスクのマウント先で、キャッシュディレクトリをここに指定すると、手で明示的にキャッシュを消さずとも、イメージのレイヤーにキャッシュが含まれなくなります。

ただし、この方法を利用する場合は、たいてい/dev/shmのサイズが足りなくなる*1ため、docker buildコマンドの--shm-sizeオプションで十分なサイズを指定してやる必要があります。一つめの方法を採用する方が面倒が少なくて楽でしょう。

FROM node:alpine

WORKDIR /usr/src/app
COPY package.json yarn.lock ./
ENV YARN_CACHE_FOLDER=/dev/shm/yarn_cache
RUN yarn install --production --frozen-lockfile

COPY . .
RUN yarn build

EXPOSE 3000
CMD ["yarn", "start"]
$ docker build --shm-size 1gb -f Dockerfile.dev-shm-yarn-cache

さらにイメージサイズを小さくするには、アプリケーションの実行に不要なコードをイメージに含めないようにするとよいです。テストコードは実行に関係ないので削除できますし、Next.jsのようにソースコードをトランスパイル・ビルドしている場合は、ビルド元のコードは不要です。

必要なファイルのみイメージに含める方法はいくつかありますが、ファイルの削除し忘れなどのミスを避けるには、multi-stage buildを使うとよいです。これはNext.jsのドキュメントでおすすめされている方法と同様です。この方法だと、Yarnのパッケージキャッシュを手で削除する必要がないのも楽です。

FROM node:alpine AS deps
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
RUN yarn install --production --frozen-lockfile

FROM node:alpine AS builder
WORKDIR /usr/src/app
COPY . .
COPY --from=deps /usr/src/app/node_modules ./node_modules
ENV NODE_ENV=production
RUN yarn build

FROM node:alpine AS runner
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/public ./public
COPY --from=builder /usr/src/app/.next ./.next
COPY --from=builder /usr/src/app/node_modules ./node_modules
EXPOSE 3000
CMD ["yarn", "start"]

では、それぞれのイメージのサイズを比較してみます。

$ docker images nextjs-docker --format "table {{.Tag}}\t{{.Size}}" | sed -e '1d' | sort -k2 -h -r 
simple               1.17GB # Debianベース
alpine               399MB # Alpineベース
install-production   315MB # yarn install --production
dev-shm-yarn-cache   157MB # キャッシュディレクトリを /dev/shm 以下にする
clean-cache          157MB # yarn cache clean
staged               156MB # Multi-stage build

結果、Next.jsのドキュメントでおすすめされている方法でビルドしたイメージが一番小さくなりました。全く最適化していないものに比べて1/8以下のサイズです。ベースイメージを変更したのが一番影響がありますが、他の手法もMB単位でサイズが小さくなっています。Multi-stage buildはこの比較だとあまり意味がないように見えますが、実際のアプリケーションでは実行時に不要なファイルがこのサンプルより多いでしょうから、ここで示したよりも効果が出るでしょう。

*1:デフォルトは64MB

ランダムな英単語を組み合わせた文字列が欲しいときのワンライナー

Githubのリポジトリ名サジェスト機能のように、ランダムな英単語をふたつ組み合わせて出力したくなったので、シェルスクリプトでワンライナーを書きました(macOS以外では動かないかも)。

$ cat /usr/share/dict/words | sort -R | head -2 | tr '[:upper:]' '[:lower:]' | paste -d '-' - -
multilocation-misallotment

2単語以上出力したい場合は、headコマンドのオプションを変えてpasteコマンドの - をその数だけ繰り返します。

$ cat /usr/share/dict/words | sort -R | head -5 | tr '[:upper:]' '[:lower:]' | paste -d '-' - - - - -
peribronchiolar-telltalely-broom-tribracteolate-benzoylformic

ハイフンではなく直接単語同士をつなぎたい場合は '-''\0' に置き換えます。

$ cat /usr/share/dict/words | sort -R | head -2 | tr '[:upper:]' '[:lower:]' | paste -d '\0' - -
anthracotheriidaexenelasia

Githubのようにあらかじめ単語を選んでるわけではないですが、何度か実行しているとなかなかかっこいい文字列が出てきます。

$ cat /usr/share/dict/words | sort -R | head -10 | tr '[:upper:]' '[:lower:]' | paste -d '-' - -
physicalness-deliquescent
biceps-foamily
tralatitious-trimetrical
trichiuroid-sewerless
nonfamous-schedar

見たことも聞いたこともない単語ばかり出てきますが、辞書で意味を調べてみると公の場で使うのは憚られそうな単語が入っていることがあるので、チェックしてから使うとよさそうです。

Github Actionsでyesコマンドを使いたい時は代わりにecho yするといい

こんばんは、id:maku693です。

タイトルでほぼ全部説明しましたが、Github Actionsでちょっとハマりました。

Github Actionsのワークフローでは工夫しないとyesコマンドを使えないので、代わりにecho yを使うと手っ取り早いです。

これに気づいたのは、[y/N]どちらかの入力を待つことで処理を続行していいか聞いてくるプログラムをワークフロー中で使いたかったので、yesコマンドを使ってyを入力させようとしたところ、yes: standard output: Broken pipeというエラーが出て正常にワークフローが終了しなかったためです。

手元 (macOS) で実行してもエラーにならないので不思議に思って調べてみると、bashにpipefailオプションが指定されている*1のと、yesコマンドがSIGPIPEを受け取って終了する前提で使われているのが原因でした。

詳しく説明すると、例えば

$ yes | head -10

のようなコマンドを実行したとき、“y”と10行表示されyesとheadが同時に終了するように見えますが、実際には同時に終了するわけではなく

  • headが10行読み終わったので終了する(パイプを読み取るプロセスがなくなる)
  • yesがパイプに書き込もうとするが、読み取り先がないのでOSがyesにSIGPIPEを送る
  • SIGPIPEを受け取ってyesコマンドがエラー終了(exit status が0以外)する

という流れで処理が進むようです*2*3*4

ここでbashにpipefailオプションが指定されていると、yesがエラー終了することによりパイプの途中で処理が失敗したとみなされます。このため、シェルのexit statusが0以外になり、ワークフローのステップも失敗します。手元でうまく動いたのはpipefailオプションが指定されていなかったからでした。

これを回避するには、set +o pipefailなどとしてbashのオプションを上書きする手もあったのですが、今回の用途では“y”を何度も出力しなくてもよかったため、よりお手軽な対応としてecho yで一度だけ“y”を出力させることにしました。

一応yesを使っているコードがないかGithubで検索してみましたが、ざっとみた限り無理やり使っている例もあまりなさそうです。

Asana APIのNode.jsクライアントを使ってタスクを作る

仕事で自分が所属しているチームではプロジェクト管理にAsanaを使っています。

先日、大量のタスクを一気に登録する必要が出てきたのですが、単にタスクを追加するだけではなく、同時に説明もまとめて入力したかったため、いちいち手でやるのも面倒だったのでAPIを使って登録してみました。

Asana APIを利用するにはアクセストークンが必要です。今回は書き捨てのスクリプトなので、Developer ConsoleからPersonal Access Tokenを取得して利用しました。

アクセストークンが準備できたらコードを書いていきます。

まずはライブラリをインストールします。

$ mkdir create-asana-tasks
$ cd create-asana-tasks
$ yarn init -y
$ yarn add asana

JSを書いてindex.jsとして保存します(ACCESS_TOKEN, PROJECT_IDは適宜差し替えてください)。

const asana = require("asana");

const client = asana.Client.create().useAccessToken("ACCESS_TOKEN");

(async () => {
  const project = await client.tasks.getProject("PROJECT_ID");
  console.log(project);

  const tasks = [
    { name: "タスク1", notes: "タスク1です" },
    { name: "タスク2", notes: "タスク2です" },
    { name: "タスク3", notes: "タスク3です" },
  ].reverse();
  for (task of tasks) {
    const result = await client.tasks.create({
      ...task,
      projects: ["PROJECT_ID"],
    });
    console.log(result);
  }
})();

このソースコードに書いてあるタスクはダミーですが、実際にはタスクの配列は元々Googleスプレッドシートに載せていたものをテキストエディターにコピペしてJSの配列に加工しました。

配列をArray#reverse()で逆順にしているのは、APIで作成したタスクはプロジェクトのリストの一番上に順に追加されていくので、最終的に配列の順番とは逆になってしまうのを避けるためです(逆順で追加するとリスト表示にしたとき配列リテラルで定義した順に並びます)。

ちなみに、AsanaのURLはhttps://app.asana.com/0/{ProjectID}/{TaskID}という構造になっているので、タスクかプロジェクトであればURLがわかればAPIを叩かなくても対象のIDがわかります。

実行するとタスクが追加されます。

$ node ./index.js

AsanaのAPIクライアントはNode.js以外にもPython, Java, PHP, Rubyのライブラリがあるようです。慣れている言語でAPIが叩けると便利ですね。

TypeScriptである型の特定のプロパティの型を得る方法

type I = {
  func: (arg: string) => void;
};

という型があった時、Iの func プロパティの型を得るには、

const f: I["func"] = (arg) => {
  console.log(arg);
};

のように I["func"] とすると取得できます。

これはGenericsを使った型でも利用可能です。

type I<T> = {
  func: (arg: T) => void;
};

const f: I<string>["func"] = (arg) => {
  // arg は string
  console.log(arg);
};

デフォルト型引数が存在する場合は変数側での型引数の指定は必須ではなく、型推論も効きます。

type I<T = Record<string, unknown>> = {
  func: (arg: T) => void;
};

const f1: I["func"] = (arg) => {
  // arg は Record<string, unknown> として扱われるので `[]` で要素にアクセスできる
  console.log(arg["foo"]);
};

ライブラリにこういった複雑な型を渡す必要があるが、ライブラリ側からプロパティの型がexportされていない場合などに便利です。

TypeScriptでは Partial<T>Pick<T, K> などである型を変換した型 (Mapped Types) を作成できるので、特定のプロパティを抜き出すのもこういったUtility Typesでやるのかと思い込んでいたのですが、そうではなく T["property"] と書く、ということになかなか気付けませんでした。

VSCodeでTS/JSの静的解析を強制的に再実行したいときはTSファイルを開いてTypeScript: Restart TS Serverコマンド

タイトルで説明終わってますが知らなかったという日記です。

VSCodeにはデフォルトでTS/JS拡張が含まれており、TS/JSファイルを開くと静的解析を実行してくれますが、たまにファイルの更新に追従できていなかったり、npm installしたあとに暴走したりと挙動が怪しくなる場合があります。

今まではおかしくなったときはVSCodeを丸ごとリロード(Developer: Reload Windowコマンド)していたのですが、TSとは関係ない拡張も再読み込みされてしまったり、エディタ全体の再描画が終わるのを待つ必要があったりと、起動に時間がかかるのが不満でした。

なんとかならないものかと思って「reload」とか「language server」などとコマンドパレットに入力してみたのですが、何も出てこなかったのでググってみたところ、普通にコマンドがあったというオチでした。

TS Serverって呼び方が全然思いつかなかったし、TS/JSファイルを開くなど特定の操作を実行しないと拡張が有効化されずにコマンドが出てこない*1*2のが難しかった。

Catalina node-gyp インストールできない 直し方

こんにちは、id:maku693 です。

先日私物の Mac の OS を Catalina にアップグレードしたせいか、node-gyp を使う npm モジュール(fsevents など)がインストールできなくなっていました。
特に fsevents が利用できないと困る*1ので、直す方法はないかと調べていたところ、バッチリ対応方法を記したドキュメントがありました

自分の手元では↑のドキュメントに記載のテスト

/usr/sbin/pkgutil --packages | grep CL

を実行してみて何も表示されなかった(テストに通らなかった)ので、

sudo rm -rf $(xcode-select -print-path)
sudo rm -rf /Library/Developer/CommandLineTools
xcode-select --install

の3コマンドを実行したところ、node-gyp を使うモジュールがインストールできるようになりました。

*1:webpack や TypeScript コンパイラなどのツールは fsevents を使ってファイルの更新を監視します。fsevents が使えない場合はポーリング実装にフォールバックされるので、使える場合に比べてリソースを余計に持っていかれます

Go言語で画像をリサイズするツールを作ってみました

こんばんは、id:maku693です。Go言語の練習としてimgresという画像リサイズツールを作ってみました。Goの標準ライブラリにGIF, JPEG, PNGのエンコーダー・デコーダーがあるのでそれを使っています。


入力ファイル名、出力画像の幅か高さ、出力ファイル名を指定すると、縦横比を保ったまま画像をそのサイズに収まるようにリサイズし、入力画像と同じフォーマットで保存します。

$ imgres -in gtsport.jpg -width 200 -out gtsport300.jpg

f:id:maku693:20200114020656j:plain
グランツーリスモSPORTで撮影した高解像度画像もこのとおり


入出力ファイル名の指定は必須ではなく、指定がなかった場合は標準入出力を利用します。

$ imgres -width 100 < cornellbox.png > cornellbox100.png

f:id:maku693:20200114020143p:plain
コーネルボックスです


こだわりポイントは指定された幅・高さと元画像の縦横比が異なる場合に、画像全体を収める (contain) か、はみ出させる (cover) か切り替えられる機能です。縦横比が異なる画像をたくさん扱うときに便利です。デフォルトでは収まるようにリサイズします。

$ imgres -in gtsport.jpg -width 150 -height 200 -fit cover -out gtsport150x200.jpg

f:id:maku693:20200114020532j:plain
150x200をはみ出るように(この画像の場合は高さ200pxを基準に)リサイズします


アニメーションGIFにも対応しています。実は素朴に image パッケージのインタフェースを使うだけではアニメーションGIFに対応できなかったので、フォーマットに応じてデコード・エンコードの仕組みを差し替えるようになっています(この辺は別でまた記事にしようと思います)。

imgres -in partyneopanda.gif -width 20 -out partyneopanda20.gif

f:id:maku693:20200114020010g:plain
極小partyねおぱんだです


ぜひご利用ください。

github.com