まだプログラマーですが何か?

プログラマーネタ中心。たまに作成したウェブサービス関連の話も https://twitter.com/dotnsf

2023/06

自分自身はゲームボーイ世代というよりはファミコン世代です。先日このような GBDK という SDK を見つけました:
https://github.com/gbdk-2020/gbdk-2020

2023061801


GBDK(GameBoy Development Kit) はその名の通り「C 言語によるゲームボーイの開発キット」です。クロスプラットフォーム対応しており、Windows/macOS/Linux で使えるものです(リリースノートによると他のゲームプラットフォームにも対応しているようですが、このブログエントリではゲームボーイ版に絞って紹介します)。

普段ゲームを作っているわけではないし、何より C 言語なので普段自分が使っている開発環境とも異なるのですが、C は全く経験ないわけではないし、何よりも「一度くらい自分の作ったゲームがゲームボーイ内で動くのを見てみたい」という好奇心から挑戦してみることにしました。当面の最終目的は以前にブラウザゲームとして作った「モールモール」をゲームボーイ内で動かしてみるということに設定します(「モールモール」についてはリンク先を参照してください)。

かなり苦労することになると思うので、複数回に分けて紹介していきます(このエントリを書いている時点ではまだ完成の目途もたっていません)。初回となる今回は開発環境の構築と、動作確認の "Hello World!" までを紹介します。


【環境構築】
普段の自分は Windows + WSL を使ってアプリ開発を行っていて、GBDK も Linux 版があるのでそのまま WSL でゲーム ROM を開発することもできます。できますが、作ったゲーム ROM の動作確認を行う環境としてゲームボーイエミュレータを使いたいという事情があります。WSL 内でゲームボーイエミュレータを動かすこともできないことはないと思うのですが、この動作確認部分を必要以上に複雑にしたくなかったので、今回は純 Windows 環境で開発することにします。開発環境構築自体は macOS や Linux でも Windows とそんなに変わらないと思っています。

まず GBDK のダウンロードサイトから自分の環境向け最新版(2023/06/18 時点での最新バージョンは 4.1.1、また自分の場合は gbdk-win64.zip)をダウンロードし、適当なフォルダに展開します。以下の例では C:\MyApps というフォルダ内に展開したものと過程して紹介します:
https://github.com/gbdk-2020/gbdk-2020/releases

2023061801


あっけないですが、これで GBDK の準備は終わりです。展開先の examples フォルダ内にサンプルがたくさんあります。ソースコードは実際にゲームで使うようなグラフィック処理が含まれているものが多く、見てもすぐには理解できないかもしれませんが、各サンプルフォルダごとに含まれている Makefile や compile.bat を見ておくことでコンパイル手順などは参考にしやすいと思っています。実際に以下で紹介する Hello World サンプルでも参考にしました。

加えて、ゲーム ROM を作った後の動作確認用にゲームボーイエミュレータをインストールしておきます。ゲームボーイエミュレータは色々リリースされているようで、インストールするのはなんでもいいと思うのですが、自分は mGBA というものをインストールしました。なお mGBA の場合、各キーとボタンのマッピングはデフォルトで以下のようになっていました。この情報は今回の Hello World の時点では不要ですが、いずれゲームを作って動作確認していく中で必要になっていくはずです:
ゲームボーイのボタンPC キー
UP上矢印
DOWN下矢印
LEFT左矢印
RIGHT右矢印
AX
BZ
SELECTBackspace
STARTReturn



【ハローワールド】
新しい環境での第一歩は「ハローワールド」です。ゲームボーイでもそこは譲れません。

最終的に作るゲームにおいてはやはりグラフィックを使ってスプライト処理やスクロールなども含めて作りたい希望はありますが、まずはテキストコンソールを使ったものから順を追って作っていこうと思います。

まずは適当なフォルダ内に "helloworld" という名前のフォルダを作っておきます。この後作成するファイルや作業はすべてこのフォルダ内で行います。

そして helloworld フォルダ内に helloworld.c というテキストファイルを以下の内容で作成します(太字部分で "Hello World!" を出力しています):
#include <gb/gb.h>
#include <stdint.h>
#include <stdio.h>

void main( void ){
    printf( "Hello World!" );

    CRITICAL {
        add_SIO(nowait_int_handler);
    }
    set_interrupts(SIO_IFLAG);
}

Windows の場合は同様にしてもう1つ compile.bat というファイルを以下の内容で作成します(最初の c:\MyApps\gbdk 部分は GBDK を展開したフォルダ名を指定してください):
c:\MyApps\gbdk\bin\lcc -Wa-l -Wl-m -Wl-j -o helloworld.gb helloworld.c

macOS や Linux の場合、compile.bat は不要ですが、代わりに以下の内容で Makefile というファイルを作成してください(一行目の CC の値は GBDK を展開した先を参照するよう書き換えてください。この例では自分のホームフォルダ内に展開した例として記載しています):
CC	= ~/gbdk/bin/lcc -Wa-l -Wl-m -Wl-j

BINS	= helloworld.gb

all:	$(BINS)

# Compile and link single file in one pass
%.gb:	%.c
	$(CC) -o $@ $<

clean:
	rm -f *.o *.lst *.map *.gb *~ *.rel *.cdb *.ihx *.lnk *.sym *.asm *.noi


この時点で Windows だと以下のようなフォルダ構成になっています(macOS や Linux の場合は compile.bat の代わりに Makefile があるはずです):
2023061802


compile.bat と Makefile とで違いはありますが、どちらも「helloworld.c をコンパイルして helloworld.gb を作る」ための内容になっています。

これで準備完了です。早速コンパイルしましょう。Windows の場合はコマンドプロンプトを開いて helloworld.c のあるフォルダに移動し、compile.bat を実行します(正しく実行できた場合、実行結果として helloworld.gb が出来上がっていることが確認できます。これがゲーム ROM です):

2023061803


macOS や Linux の場合はターミナルで helloworld.c のあるフォルダに移動して make コマンドを実行します。正しく実行できると同様に helloworld.gb が出来上がっていることが確認できます:
2023061804


最後に完成したゲーム ROM をエミュレータで動作確認してみます。まずエミュレータを起動(下図は mGBA の場合):
2023061805


次に上の手順で作った helloworld.gb を読み込みます(mGBA の場合はメニューから ファイル - ROM をロード で helloworld.gb を指定):
2023061806


以下のような画面になれば成功です。ゲームボーイで Hello World! ができました!:
2023061807


とりあえず今回はここまで。次は移動キーや A, B, Start といったボタン操作に対応するものに改良していき、少しずつゲームっぽいものにしていくつもりです。


ChatGPT で話題になっている OpenAI からは API や SDK が提供されており、これらを使うことで、自作アプリケーションに ChatGPT のような機能を組み込むことができるようになります。API 自体は無料登録でも(バージョン 3.5 以下を)利用することができますが、有料版にすることで(精度が話題になっている)バージョン 4.0 の API を使うこともできるようになる、というものです。

・・・というものなのですが、実際に ChatGTP と同様に問い合わせをする API を実行してみるとかなりの確率で 429 という HTTP ステータスコードのエラーが返されます。この 429 は "Too many requests" を意味するエラーコードです。特に無料登録の場合は利用回数に制限があったり、API 実行時のリソース割り当て優先度が低く設定されてしまい、「(他に使われていて)混み合っている時は実行できない」ということが頻繁に発生するのでした。

この問題に関しては OpenAI の記事でも取り上げられていて、「(API 実行時に)"exponential backoff" で実行するようにしてほしい」と紹介されています:
https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors


↑の記事では(exponential backoff 関数が標準で使える) Python での簡単なサンプルが紹介されています。個人的には JavaScript で実装することが多いので JavaScript のサンプルを探してみました。OpenAI API ではない例で exponential backoff を JavaScript で実装している例は見つけることができた(↓)のですが、OpenAI API のサンプルは軽く探した限りでは見つかりませんでした:
https://advancedweb.hu/how-to-implement-an-exponential-backoff-retry-strategy-in-javascript/


・・・というわけで自分で作ってみました、というブログエントリです。Node.js での実装サンプルはこちらの GitHub で公開しています:
https://github.com/dotnsf/openai-api


【そもそも exponential backoff とは?】
"exponential" は「指数的」という意味で、「指数的に増加する段階ウェイトをかけながら(何かを)繰り返し実行する」ことです。
2023060800


今回の例だと(ChatGPT の)API を実行して 429 エラーになった場合、1秒待って再実行し、また 429 エラーになったら1秒待って再実行し、・・・ という方法ではありません。これだとウェイトは増加することなく、常に1秒待って再実行を繰り返しているだけです。

「指数的」という名前の通り、ウェイトに指数を使います。例えば「2の〇乗」という指数を使った場合で計算すると、まず API を実行して 429 エラーになった場合、まず(2の0乗=)1秒待って再実行し、また 429 エラーになった場合、次は(2の1乗=)2秒待って再実行し、また 429 エラーになった場合は次は(2の2乗=)4秒待って再実行し、また 429 エラーになったら(2の3乗=)8秒待って再実行し、・・・ といった具合にウェイト時間が指数関数で増えていくようなループ処理を行うことになります(上の GitHub 公開サンプルの場合、秒ではなくミリ秒の単位で計算しています)。これが exponential backoff です。

特定条件下で 429 エラーが高い確率で返される可能性が高いとわかっているような API を使う時の、ベストプラクティス的な実装方法のようです。


【実装例】
Node.js(JavaScript)での実装例を紹介します。例えば exponential backoff 実装前はこのような形で実行していました:
var { Configuration, OpenAIApi } = require( 'openai' );
var configuration = new Configuration({ apiKey: "(API KEY)", organization: "" });
var openai = new OpenAIApi( configuration );
  :
  :

var option = {
    model: "gpt-3.5-turbo",
    prompt: "海をテーマにしたおススメ映画を教えてください",
    max_tokens: 4000
};

try{
  var result = await openai.createCompletion( option );
}catch( err ){
}

SDK を使って単純に createCompletion メソッドを実行しています。これだと 429 エラーが発生した時にそのまま 429 エラーを返すことになります。

ここを exponential backoff を実装して作り変えたものがこちらです(青字部分が変更箇所です):
var { Configuration, OpenAIApi } = require( 'openai' );
var configuration = new Configuration({ apiKey: "(API KEY)", organization: "" });
var openai = new OpenAIApi( configuration );
  :
  :

const wait = ( ms ) => new Promise( ( res ) => setTimeout( res, ms ) );
const progressingOperation = async ( option ) => {
  await wait( 10 );
  try{
    var result = await openai.createCompletion( option );
    return {
      status: true,
      result: result
    };
  }catch( e ){
    return {
      status: false,
      result: e
    };
  }
}
const callWithProgress = async ( fn, option, maxdepth = 7, depth = 0 ) => {
  const result = await fn( option );

  // check completion
  if( result.status ){
    // finished
    return result.result;
  }else{
    if( depth > maxdepth ){
      throw result;
    }
    await wait( Math.pow( 2, depth ) * 10 );
	
    return callWithProgress( fn, option, maxdepth, depth + 1 );
  }
}

var option = {
    model: "gpt-3.5-turbo",
    prompt: "海をテーマにしたおススメ映画を教えてください",
    max_tokens: 4000
};

try{
  //var result = await openai.createCompletion( option );
  var result = await callWithProgress( progressingOperation, option, 5 ); 
}catch( err ){
}


この実装ではデフォルトで最大7回まで再実行する想定で、失敗した後に(2 の試行回数乗) * 10 ミリ秒待ってから再実行する、という形で実装しています。


・・・ただ、OpenAI の無料枠の混雑具合は相当なものらしく、この方法で API を実行しても結局 429 エラーとなるだけっぽい気がしています。10 回くらい試してもいいのかな?

某所で話題になっていた記事を読んで、自分にも興味ある内容だったので参戦してみました:

絶対に画像をダウンロード&スクレイピングさせないWebページを本気で作ってみた


内容は上記サイトのタイトル通りです。自分はマンホールマップお絵描き日記といった画像投稿機能を持つサービスを開発・運営しています。そんな中でサービスに参加していただいたユーザーが投稿した作品を「勝手にダウンロードして別のところで使われる」ことはサービス運営の観点で好ましくない自体ではあります。一方で「ウェブで閲覧できる画像のダウンロードを技術的に禁止する」のは結構難しいテーマで、これまで真剣に取り組んでいませんでした(ブロックチェーンを使ったり、作品の NFT 化といった対処は既に実装しているのですが、これらはいずれも「オリジナル作品のダウンロードそのものを禁止する」という技術ではありません)。で、上記サイトを見て、改めて自分でも本気でこのテーマに取り組んでみようと思ったわけです。


【画像のダウンロード禁止がなぜ難しいか?】
ここからは少しウェブの知識を必要とする内容になります。まずは「そもそも何故ウェブ画像のダウンロード禁止が難しいか?」を説明します。

普通のウェブページで普通に画像を表示しようとすると、<img> という HTML タグを使って表示します。例えばこんな感じです:
<img src="https://avatars.githubusercontent.com/u/3183150?v=4" />


<img> というタグが画像を表示する機能を持ち、その "src" という属性で画像の URL(場所)を指定します。この例であれば "https://avatars.githubusercontent.com/u/3183150?v=4" が画像の URL になっています。この URL をウェブブラウザで直接指定すると画像だけが表示されます(他にも <img> の属性があり、指定内容によって見え方を変えることができますが、ここでは省略します)。このような「普通の」画像は画像上を右クリックしてから「画像を保存」することで簡単にダウンロードできてしまいます:

2023060403


このように「ウェブで画像を表示するには <img> タグを使う」のですが、「<img> の src 属性に画像の URL が指定されている」ため、ウェブページの HTML を見ることでどこに画像があるかが分かってしまうのです。ウェブページそのものをうまく作ることで右クリックを禁止する(右クリックのメニューからの「画像を保存」をできなくする)ことはできても、画像の URL がわかってしまうと、その URL を直接指定して開くことで「うまく作ったつもりの」ウェブページの仕組みを回避して保存できてしまうことになります。ウェブページの HTML を開けなくすることはできないため、「HTML を見られても画像の URL やデータがバレないようにする」必要がありました。

ややこしいことに「スクリーンショットで画像を保存する」ことへの対策も必要です。Windows や macOS のスクリーンショットで画像が表示されている状態のウェブページを丸ごとコピーして、画像アプリなどにペーストし、必要であれば画像部分を切り抜いて保存する方法です。この方法を使うと厳密な意味では画像の解像度なども変わってしまうのですが、そこまで気にしなければ(少なくともウェブや PC の画面で見る上では)ほぼ同じ画像を手に入れることができてしまいます。これは最早ウェブだけでどうにかできる話ではなく、OS レベルで対策が必要になるため、技術的にも非常に面倒な対策が必要になるものです。

そしてもっと面倒なこともあります。「開発者ツール」とか「Developer Tool」と呼ばれるウェブページ開発時に使われる確認ツール(ウェブブラウザの機能)があるのですが、これを使うと HTML 上ではわかりにくい画像(JavaScript を使ってプログラミングで動的に作成する画像)なども検知できてしまいます。ここまで含めて対策するのはかなり厄介といえます(なので私も対策の優先度を下げていました)。

以上のようにウェブで画像のコピーやダウンロードを禁止しようとすると、以下3つの対策が必要になります:
  1. HTML 内に画像の URL を残さない
  2. スクリーンショットへの対策
  3. 開発者ツールへの対策

【上記サイトでの対策方針】
先述した上記サイトではこれら3つの対策が全て実装されていました。内容を読んだ私の理解が正しければ以下のような方針が取られていました:

1. および 3. への対策

  • <img> タグを使わず、JavaScript を使って <canvas> タグ内に画像を描画して表示する
  • サーバー側で画像データを暗号化し、ブラウザ側で復号化し、データ URL("data:image/png;base64,xxxxxx...")形式で画像データを扱う
  • JavaScript は難読化する
  • (ここまでやればあまり意味はなく、ページ内でも「おまけ」と表現されていますが)ページ内で右クリックを無効化する JavaScript を実装する


2. への対策

  • オリジナル画像を表示せず、コピーライト文章を画像に上書きして表示する(上述の <canvas> 内にテキストを上書きして表示する)


まず目からウロコだったのは 2. への対策でした。表示される画像がオリジナルのものではないので(画像の右下にコピーライト文章が上書きされた画像なので)スクリーンショットを撮っても元画像は保存できない、という考え方でした。なるほど、その手があったか、、という印象でした。

加えて、<img> を使わずに(HTML5 の)<canvas> を使って画像を表示すること & 暗号化された画像データを受け取って復号化すること によって、HTML 内の情報から画像データを推測したり取り出したりすることを困難にする、ということも行っているようです。データ URL のテキストすら残さないやり方なので、かなり徹底した対策になっていると感じました。

一方で、同ページ内にも書かれていましたが、これでもまだ 100% の対策とは言えない要素もあります。JavaScript は難読化されているとはいえ解読不可能というわけではないため、暗号化したデータを復号化するコードを特定されてしまうと、同じロジックでデータを復号化して画像データを取り出し、その画像を保存する、ということは不可能ではないのでした。

また、これは私自身の印象なのですが、1枚の画像を守るためのページでこれだけの処理をするのはまだいいとして、画像が一覧で表示されるようなページで、全ての画像にこれだけの処理を実行して守った場合に、ページそのものの処理がかなり重くなってしまうのではないか?という懸念を感じました。例えば1ページ内に 100 枚の画像があったら、これだけの処理をブラウザ内で 100 回行う必要があるわけです。検索の仕組みなどがあると、検索結果となる画像全てにこの処理を行う必要もでてきます。今自分が開発・運営しているサービスの中でこの方法をそのまま実装するのは・・・ と感じています。


【自分で考えた対策方針】
では、実際に自分のサービスに実装する前提で、自分だったらどうやって画像のダウンロードを禁止するかを考えてみました。

ヒントとなったのは上述の 2. への対策(スクリーンショット対策)として書かれている方法でした。これはダウンロードそのものを禁止するとか、HTML からオリジナル画像にアクセスするための痕跡を消すとかいう考え方ではなく「そもそもオリジナル画像ではなく、コピーライト文章を上書きした画像を表示することで、オリジナル画像の保存を不可能にする」という方法および実装でした。これを自分のサービスの特性に合わせて応用する方法を思いつきました。

たまたま自分が実装を考えていたウェブアプリは「(IDとパスワードによるログインで)ユーザーの認証」を行うタイプのものでした。ログインしなくてもアプリを参照することはできますが、画像投稿はログイン後でないとできないタイプのものです。この場合は以下のような方法でもオリジナル画像のダウンロードを禁止することができるのではないかと考えました:

1. 画像の参照はオブジェクトストレージなどに格納して URL で参照するのではなく、画像の ID をパラメータに受け取って画像(バイナリ)情報を返す REST API の形で実現する。
2. ユーザーのログインをサーバー側でセッションとして管理する(セッション管理は必須ではないが、1. の REST API リクエストがどのユーザーからのものかを識別できるような仕組みを用意する)
3. 1. の REST API をリクエストしてきたユーザーが画像の持ち主と一致した場合は画像データをそのまま返す。リクエストユーザーが画像の持ち主とは別ユーザーだったり、そもそもログインしていないユーザーからのリクエストだった場合は画像データに加工(画像の一部にコピーライトメッセージを上書き)してから返す。

例えば画像 ID が xxxx である画像を表示するための REST API を "https://servername/attachment/xxxx" のように用意します。この URL にログイン済みのユーザーから、かつその画像の持ち主ユーザーからリクエストがあった場合のみ投稿された画像をそのまま返し、それ以外の場合は画像を加工してから返す、とすればいいのではないかと考えました。特定の画像を表示するための URL は(ログイン有無やユーザーの違いに関係なく)常に一意でいて、しかし返される画像のバイナリ情報はユーザーによって異なり、オリジナルの画像が返されるのは投稿ユーザーからのリクエストだった場合だけ、というものです。この方法だと同じ URL でも返される内容が異なるのでキャッシュは使えなくなりますが、既存のウェブアプリからの変更箇所は(REST API のパス部分に変更がないので、画像を返す REST API の中だけを変更し、セッション内のユーザーと画像の投稿者が同一であることを確認し、異なっていた場合のみコピーライトメッセージを上書きする処理を追加するだけで済むので)UI 側にほとんど変更がなく、比較的影響範囲が小さくなると思いました。

更にこの方法では、

- 画像を表示する方法はあくまで1つなので(パラメータも同じ REST API なので)、画像 URL を隠さなくても投稿者本人がログインした上でのリクエストでない限りはオリジナル画像を取り出すことができない(複雑な仕組みで画像 URL を隠す必要がない)

- 複数の画像が表示される一覧ページでも、1枚の画像が表示される詳細ページでも HTML や JavaScript に特別な違いを意識することなく同様に表示することができる

という副産物のメリットもあります。

加えて、これは私のウェブアプリでは 1 と 2 の部分はほぼ実装済みであったため、実質 3 の変更だけをすれば済む形になりました。自分の設計的にもこの方法が効率的だったという理由もあります。


【実装してみた】
実際に自分が運用しているお絵描き日記サービスでこの機能を有効にしてみました。興味ある方は実際に参照してみて(よかったら ID も作成してみて)ください。

まずログイン前にトップページを表示するとこのように表示されます(右上のボタンが黄色の場合はログイン前です)。表示される全てのお絵描き画像は画像を取り出す REST API 経由で取得しており、今回はログインしていないユーザーからのリクエストなので、全てのお絵描き画像にコピーライトメッセージ(図の青枠部分、実際の画像には青枠はありません)が付与されています。お絵描きの一覧ページでも、後述の個別ページでも同様です:
2023060400


ちなみにこのコピーライトメッセージ部分を拡大するとこのように表示されます。赤字で "© (投稿者の ID)@MyDoodles" と表示されています。"MyDoodles" はアプリ名です。オリジナルのお絵描き画像にはこのコピーライトメッセージはなく、API がこのメッセージをお絵描き画像の右下に上書きした上で結果を返しています:
2023060401


画面を下にスクロールすると色んな利用者から過去に投稿されたお絵描き画像が表示されていきます。この時点ではサービスにログインしていないので、全てのお絵描き画像にコピーライトメッセージが上書きされて表示されています:
2023060401


特定の1つのお絵描き画像を選択すると、そのお絵描き画像画像の詳細画面に移動します。このお絵描き画像は本当は私自身が投稿したものですが、まだログインしていないので(投稿者本人かどうか確認できないので)ここにもコピーライトメッセージが表示されています:
2023060402


別の利用者が投稿したお絵描き画像を詳細画面で表示した場合も、同様にコピーライトメッセージが上書きされた画像として表示されます:
2023060403



次にログイン後に同じページを参照してみます。画面右上の黄色いログインボタンをクリックしてログイン(またはサインアップしてからログイン)します:
2023060400


ログインに成功すると画面右上のボタンは緑になり、自分のアイコン画像に切り替わります。ログイン後の一覧画面では自分が投稿したお絵描き画像は(コピーライトメッセージが付与されず)オリジナル画像の状態で表示され、自分以外の利用者が投稿したお絵描き画像にはコピーライトメッセージが残る形で表示されます:
2023060401


下にスクロールして先ほどと同じ部分を見ています。上のお絵描き画像は私が投稿したお絵描きなので、今度はコピーライトメッセージが表示されていません。下のお絵描き画像は私以外の利用者が投稿したものなので、私の ID でログインしていてもコピーライトメッセージが表示されます:
2023060402


各お絵描き画像を詳細画面で見た場合です。私のお絵描き画像にはコピーライトメッセージがなく、他ユーザーのお絵描き画像にはコピーライトメッセージが上書きされて、それぞれ表示されていることがわかります:
2023060403



2023060404


繰り返しますがログイン前後で画像表示部分の HTML は変わっていませんが、ログインの有無によって同じ REST API の返り値が変わる形で実装しているので、画像データそのものの取得はシンプル(<img src="xxxx" /> 部分はログイン前後で全く同じ)です。しかしここがシンプルなだけに、画像表示部分の HTML や JavaScript を見られたとしても、投稿したユーザーの ID でログインしない限りはオリジナル画像を取得する方法がない、という形で実装できたことになります。


最初は「どうやってオリジナル画像の情報を隠そうか」と色々な実現方法を考えていたのですが、スクリーンショット対策の「オリジナル画像ではない画像を表示する」というアイデアを応用させる形でこの方法を思いつきました。この方法は上でも書いている通り「キャッシュを有効にできない」という弱点はあるものの、(自分のように笑)そこまでアクセスの多くないログイン付きサービスで、API 取得以外の方法で画像にアクセスできないようにすることができれば、投稿者本人だけはオリジナル画像に簡単にアクセスできるというメリットまで含めて、この実装方法はかなり現実的なものになり得ると考えています。

これで投稿者のオリジナル画像の無断コピーを(投稿者本人以外からは)絶つことができそう、と思う。近いうちにマンホールマップにもこの実装を移植しよ。

※回避できるツワモノがいらっしゃったら教えてください。 m(__)m



ネットにあまり情報がなく、自分で試行錯誤しながら色々調べてわかった備忘録的なネタです。

フロントエンドフレームワークではない、サーバーサイドアプリとしての Node.js で使える node-canvas というパッケージモジュールがあります:
https://www.npmjs.com/package/canvas

2023060100



これは HTML5 の Canvas 互換機能をサーバーサイドで使えるようになるモジュールです。UI のないサーバーサイドで HTML5 の Canvas ??と思う人がいるかもしれませんが、例えば画像を生成して返すような API を作ろうとした際に(仮想的な)Canvas を生成してグラフィックを描画し、最後に Canvas を画像化して(image/png などのコンテントタイプを指定して)出力する、といった使い方ができて便利です:
var { createCanvas } = require( 'canvas' );

  :
  :

app.get( '/image', function( req, res ){
// (仮想的な)Canvas を生成 var canvas = createCanvas( 200, 100 );

// グラフィックコンテキストを取得(ここから先は HTML5 の場合と同様) var ctx = canvas.getContext( '2d' );
// ctx に目的のグラフィックを描画していく
ctx.beginPath(); ctx.moveTo( 100, 0 ); ctx.lineTo( 200, 100 ); ctx.strokeStyle = 'red'; ctx.stroke(); : :
// 最後に Canvas の内容を 'imaga/png' で出力する res.contentType( 'image/png' ); var stream = canvas.createPNGStream(); stream.pipe( res ); });


便利なのですが、実はこの node-canvas は利用する上での大きな制約があります。システムにあらかじめ特定のライブラリがネイティブインストールされている必要があり、その前提でインストールすることによって使える、というものです。つまり普通にインストールしようとして "$ npm install canvas" と実行するだけでは正しくインストールできないことがあり、その前に必要なライブラリをインストールしておく必要があるのでした。例えば Ubuntu の場合は以下を実行しておく必要があります(他のシステムの場合はこちらの compiling 節を参照ください):
$ sudo apt install build-essensial libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev

さて、アプリを普通に動かすだけならこの(ドキュメントに書かれている)前提コマンドを知っているだけでいいのですが、Docker コンテナ上で動かすとなると少し事情が変わってきます。システム(= Docker ホスト)にあらかじめインストールしたライブラリは関係なく、Docker コンテナとして動かす、そのコンテナ上に上述の依存ライブラリを導入しておく必要があります。しかも Docker コンテナとして動かす場合は Dockerfile 内でベースイメージを指定することになるのですが、このベースイメージによっても(追加で)インストールが必要なライブラリが変わってきます。そういった複雑な事情がある中で、どのようにすれば Node.js で node-canvas が動くコンテナを作れるようになるか、というのがこのブログエントリの(自分の備忘録としての)目的です。もちろん自分以外で同様に Docker コンテナ内で node-canvas を使いたい人がいたとしたら、(特に日本語だとなかなかまとまった情報が見つからないので)以下の情報が有用であると思っています。

答としてはこんな感じです。node:16-alpine をベースとした場合のサンプルを記載します:
# base image
FROM node:16-alpine

# working directory
WORKDIR /usr/src/app

COPY package*.json ./

RUN apk update && apk upgrade && apk add python3 alpine-sdk

# apt install build-essensial libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
RUN apk add build-base cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev

RUN npm install

COPY . .

EXPOSE 8080
CMD ["node", "app.js"]


特に注目してほしいのは、上記 Dockerfile 内の赤字部分です。ここで "apt install build-essensial libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev" に相当するコマンドを実行して docker build 時の "RUN npm install" 実行直前に依存ライブラリをインストールしています。"npm install" でインストールする各モジュールを "apk add" 時にはどのように指定するか、1つ1つ調べる必要があり、その結果がこれでした。

この1行を追加しておくことで直後の "RUN npm install" (ここで node-canvas をインストール)も成功するようになり、Docker コンテナ内で node-canvas を使ったアプリケーションが期待通りに動くようになりました。


(補足)
"node-canvas" と互換性のある "@napi-rs/canvas" というモジュールが存在していて、こちらを使うとネイティブライブラリのインストールは不要です。絵文字などのフォントも内蔵しているようで、これはこれで便利そうです。

ただ上述のコードでは "canvas.createPNGStream()" を実行している箇所がありますが、この関数は @napi-rs/canvas では未実装らしくエラーになってしまいました。もしかするとこちらにも同様の互換関数があるのかもしれませんが未調査です。




このページのトップヘ