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

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

タグ:nodejs

ツイッター(X)が API 制約を含めて色々と不便になってしばらく経過しました。ツイッターでリツイートボットとか作ってた個人のものはほぼ全滅状態ですが、さすがに「しばらく待てばまた使えるようになる、、」などと楽観的には考えない方が良さそうな感じですね。

世の中には使いにくくなったツイッターの後釜を狙う SNS も多く出てきましたが、今のところはまだツイッターを使っている人が圧倒的多数の印象です。まあ開発者的にはツイッターはもう見限ってる人も少なくないんでしょうけど、SNS はあくまで「発信の場」であって、使っているのは開発者ばかりじゃないですからね。メッセージを届ける先の規模含めると、使い勝手が現状維持ならまだしばらくツイッターが使われていくのでしょう。


一方で、とはいえ多くの SNS が新たに生まれてきてはいます。実は自分も「お絵描き SNS」という位置づけの MyDoodles なんていうものを密かにリリースしていたりもするのですが、、、そんな中でツイッターの共同創業者の一人だったジャック・ドーシーさんが新たに立ち上げた分散型 SNS である Bluesky Social (以下 "Bluesky")はツイッターに似たインターフェースを持ち、リリースからしばらくは招待制で運用されていましたが、先ごろ招待制が撤廃されて誰でもアカウントを作れるようになりました。比較的早い段階から API も公開されており、開発者視点では「次に来るのはこれかも」と期待しています。


そんな Bluesky の API を使って、ツイッターでは(個人では事実上)作れなくなってしまったボットを作ってみることにしました。自分が作ろうと思ったのは拙作マンホールマップが持つ機能の1つである「今日のマンホール」を午前零時に自動でつぶやくボットです。実はこれまでは私自身が人間ボットになって(笑)今日のマンホールを手動でつぶやいていたのですが、これを Bluesky 内で自動化する、というものです。ボットとしての基本的な考え方はツイッターの頃と大きく変わるものではないのですが、分散 SNS である Bluesky の、まだ充分に熟しているとは言えない API を使って実装することはチャレンジでもあり、色々面倒な考慮も必要にはなるのですが、とりあえず作れそうだったので、その中身の解説をするのが本ブログエントリの目的です。なお以下で紹介する内容は 2024-02-12 時点の情報であることをご了承ください。


【開発環境】
まず Bluesky API を紹介している本家ページがこちらです:
https://www.docs.bsky.app/docs/get-started

2024021201


SDK としては Node.js と Python 向けに提供されているようですが、Curl からも使える REST API があるようなので REST API でプログラミングするつもりであれば事実上プログラミング言語に制約はないことになります。今回以下で紹介するプログラミングの環境としては自分が普段使っている Node.js を使うことにします。

Node.js の場合、@atproto/api というライブラリを使うことで比較的簡単に Bluesky API を使ったアプリケーションの開発ができるようになります。こんな感じでインストールして使います:
$ npm install @atproto/api


【認証】
Bluesky API を使って試しに自分自身の情報でも取得・・・ するのもいいですが、まずは認証を行う必要があります。ツイッターなどでは開発者向けページからアプリケーションを登録して API キーを発行して OAuth 2.0 で・・・ といった手順で認証を行っていました。Bluesky もいずれはそのような形態になるものと思いますが、現時点では OAuth2.0 はサポートされておらず、ユーザー ID とパスワードを使って直接ログインする形になるようです。ちょうど Bluesky の招待制も終わったことなので、自分はこのボット用(というかマンホールマップ用)のアカウントを1つ新たに作成しました。そして Node.js の場合はこのような形で認証(ログイン)を行います:
var { BskyAgent } = require( '@atproto/api' );

var agent = new BskyAgent({
  service: "https://bsky.social"
});
agent.login({
  identifier: "myname@email.com",
  password: "mypassword",
}).then( async function(){
  // ログイン成功後の処理へ
    :
    :
});

"@atoproto/api" ライブラリから BskyAgent をインポートして、メールアドレス(上では "myname@email.com" の部分)とパスワード(同 "mypassword" 部分)を指定してログインします。このあたりはソースコード内に直接記載するのは危険なので、コードの管理方法には注意が必要です。


【ツイートする】
ボットを作るには「ツイートする」(書き込む)機能が必要です。ログイン後に単にテキストをツイートするだけなら簡単です。特に @atproto/api ライブラリを使っていれば post メソッドを使って以下のようにログイン後の処理を一行追加するだけです:
var { BskyAgent } = require( '@atproto/api' );

var agent = new BskyAgent({
  service: "https://bsky.social"
});
agent.login({
  identifier: "myname@email.com",
  password: "mypassword",
}).then( async function(){
  var res = await agent.post( { text: 'Hello, world.'} );
  console.log( {res} );
});

これでログインしたユーザーの権限で "Hello, world." とつぶやくことができます。簡単ですよね、ここまでは。


【facets 処理(リッチテキスト処理)】
テキストをつぶやくのは簡単でした。ツイッターなどではこのテキスト内に他のユーザーへのメンション(@)が含まれていたり、ハッシュタグ(#)やリンクが含まれていると自動的に解釈してリッチテキスト化してくれて(リンクとかを付けてくれて)いましたが、Bluesky API はそうはいきません(そもそも Bluesky にはまだハッシュタグという概念がありません)。この辺りはプログラミング内でリッチテキスト化(Bluesky では「facets 検知」といいます)する必要があります。今回作ろうとしているボットも必ずあるページへのリンクを含む内容になっているので post 実行前の facets 検知が必須です。この辺から少し面倒になってきます。

具体的にはこのようなコードになります:
var { BskyAgent, RichText } = require( '@atproto/api' );

var agent = new BskyAgent({
  service: "https://bsky.social"
});
agent.login({
  identifier: "myname@email.com",
  password: "mypassword",
}).then( async function(){
  var text = "リンクを含むテキスト https://manholemap.juge.me/";

  var rt = new RichText({ text: text });
  await rt.detectFacets( agent );

  var res = await agent.post({
    $type: 'app.bsky.feed.post',
    text: rt.text,
    facets: rt.facets
  });

  console.log( {res} );
});

まずリッチテキスト化処理を行うために @atproto/api ライブラリから BskyAgent だけでなく RichText もインポートしておきます。そしてリンクなどのリッチテキスト化が必要なテキストを RichText で初期化した結果を変数 rt で受け取り、更に detectFacets() 処理を実行します。するとテキスト部分は rt.text に、facets 情報が rt.facets にそれぞれ格納されます。ツイッターではこの辺りの処理が自動化されていたので楽でしたが、Bluesky でもここまでライブラリが用意されているのでさほど大変ではないですね。

最後に facets 処理した結果を post します。先のプレーンテキストでは text 要素だけを post していましたが、リッチテキストの場合は少し情報を加えてあげる必要がありますが、これでテキスト内の URL 部分はリンクになってポストされます。


【オープングラフ処理(OGP対応)】
ツイッターなど多くの SNS ではリンクを含む情報をポストすると、そのリンク先の内容の一部(テキストや画像)が埋め込まれる形で表示されます。これは OGP と呼ばれる規格で、リンク先が OCP 規格に沿って作られたページであれば、その情報を使って実現できるものです。

Bluesky の場合もリンク先の情報を埋め込むことはできるのですが、API でつぶやく場合は(現状では)この部分を全て手作業で行う必要があります。具体的にはこんな感じです:
var { BskyAgent, RichText } = require( '@atproto/api' );
var { parse } = require( 'node-html-parser' );

var agent = new BskyAgent({
  service: "https://bsky.social"
});
agent.login({
  identifier: "myname@email.com",
  password: "mypassword",
}).then( async function(){
  var text = "リンクを含むテキスト https://manholemap.juge.me/";

  //. OGP
  var url = "https://manholemap.juge.me/";
  var resp = await fetch( url );
  var html = parse( await( resp.text() ) );
  var title = html.querySelector( "meta[property='og:description']" ).getAttribute( "content" );

  //. embed image
  var ogpImg = html.querySelector( "meta[property='og:image']" ).getAttribute( "content" );
  var blob = await fetch( ogpImg );
  var buffer = await blob.arrayBuffer();
  var response = await agent.uploadBlob( new Uint8Array( buffer ), { encoding: "image/jpeg" } );
  var embed_params = {
    $type: "app.bsky.embed.external",
    external: {
      uri: "https://manholemap.juge.me/",
      thumb: {
        $type: "blob",
        ref: {
          $link: response.data.blob.ref.toString()
        },
        mimeType: response.data.blob.mimeType,
        size: response.data.blob.size
      },
      title: title,
      description: title
    }
  };

  var rt = new RichText({ text: text });
  await rt.detectFacets( agent );

  var res = await agent.post({
    $type: 'app.bsky.feed.post',
    text: rt.text,
    facets: rt.facets,
    embed: embed_params
  });

  console.log( {res} );
});

まず URL のリンク先情報を解析するために node-html-parser ライブラリをインポートしておきます。そしてリンク先の URL(上では "https://manholemap.juge.me/")を fetch して HTML を取得します。OGP の規格に沿って作られたページであれば、その画像は HTML 内で <meta property="og:image" content="***" /> の *** 部分に指定されることになっているのでその値を取り出します。そして再度その画像 URL を fetch してバイナリデータを取り出し、agent 変数を使ってアップロードします。そのアップロード結果の情報等を使って OGP の埋め込みデータ(embed_params 変数)を作成します。埋め込みデータを作ることができたら、その値を embed パラメータに加えて post する、という流れになります。これで OGP に対応したポスト処理を Node.js で行うことができるようになりました。


【サンプルソースコード公開】
後はこの実行を自動化することになりますが、今回は自分が使っているサーバーの crontab を使って日本時間の午前零時ちょうどに実行するようにしました。具体的なコードは後述しますが、大まかには、
(1)日本時間午前零時になったら、マンホールマップの「今日のマンホール」取得 API を呼び出し、
(2)その結果からリンク先 URL やポストするテキストを生成し、
(3)リンク先 URL の OGP 情報を使って画像やタイトルなどを取り出して、埋め込み、
(4)facets 処理を加えた上でポストする

という流れを行っています。そしてそのサンプルソースコードを以下で公開しています:
https://github.com/dotnsf/bsky_manhotalk_bot/blob/main/motd.js


上では解説していない部分を1点だけ。上述のように現状の Bluesky API は認証情報を ID とパスワードを直接指定する形で実行する必要があります。上のリンク先を見るとわかるのですが、今回公開したサンプルソースコードには ID やパスワードは含まれておらず、このソースコードを実行する際の環境変数から取得するようにしています。したがってこのソースコードを実行する時に、
$ BSKY_ID=myname@email.com BSKY_PW=mypassword node motd.js

といった具合に BSKY_ID 環境変数と BSKY_PW 環境変数にそれぞれ Bluesky へのログイン ID とパスワードを指定して実行することで正しく動くようにしています。

そうして作ったマンホールマップ・ボットはこちらのアカウントで公開しています:
https://bsky.app/profile/manholemap.bsky.social

2024021200


↑ツイッターとちがって Bluesky のアカウントを持っていない人でも見ることはできますが、可能であればアカウントを作ってフォローしてください。「今日のマンホール」が登録されている日全てで午前零時になると教えてくれます(登録されていない日もあります)。

とりあえず現時点では「今日のマンホール」専用で動くボットですが、いずれハッシュタグが実装されたら #manhotalk ハッシュタグを検索して・・といった挙動や、ちょっとした会話にも挑戦するつもりでいます。

自作サービスを開発している途中で調査した内容のアウトプットです。

Node.js を使って PostgreSQL データベースを操作する場合、pg(node-postgres) ライブラリを使うのが定番だと思っています。実際これまで何度も使ってきているし、データの読み書き更新削除といった作業で特に困ったことはありませんでした。

しかし今回ちょっとしたことで詰まってしまいました。結果的には解決できたのですが、データベース内に定義されたテーブルの、列の定義情報を調べたいと思った時に「これどうやるんだろ?」となってしまいました。

もう少し具体的に説明します。例えば以下のような SQL を使って items テーブルを作成したとします:
CREATE TABLE items( id varchar(50) primary key, name varchar(100) not null, price int default 0, body text, image bytea, datetime timestamp );

このようにして作成した items テーブルの各列の定義情報(上の例の青字部分)を取り出す方法が分からなかったのでした(列名だけであれば select 文の実行結果の中に含まれるので、1行でもレコードが登録されていればそこから分かる、ということは知っていました。が、レコードが1件も登録されていないケースだったり、列名以外の型の情報まで必要な場合の取得方法が分かっていませんでした)。ちなみにこの情報は psql コマンドを使った場合はログイン後に
# \d items

というコマンドを実行することで取得できることは知っていました("items" の部分に知りたいテーブル名を指定して実行します):
db=# \d items
                          Table "public.items"
  Column  |            Type             | Collation | Nullable | Default
----------+-----------------------------+-----------+----------+---------
 id       | character varying(50)       |           | not null |
 name     | character varying(100)      |           | not null |
 price    | integer                     |           |          | 0
 body     | text                        |           |          |
 image    | bytea                       |           |          |
 datetime | timestamp without time zone |           |          |
Indexes:
    "items_pkey" PRIMARY KEY, btree (id)

この方法を知っていたので、これまであまり気にすることもありませんでした。ところがこれはあくまで psql コマンドを利用する際のコマンドであって、これをそのまま SQL として pg を使って実行すると(SQL ではないので当然ですが)エラーとなってしまいます。ではいったいどうすれば pg でこの情報をプログラムのコード内で取り出すことができるのだろうか・・・ というのが今回のブログエントリのテーマです。


結論として分かったのは、こんな感じでした:
・SQL としては実行結果にすべての列が含まれるような SELECT 文(例: "select * from items")を実行する
・実行結果からレコードを取り出す場合は result.rows を参照するが、実行結果の列情報は result.fields と result._types._types.builtins を参照することで取り出すことができる
・実行結果のレコードが0件でも(1件もレコードが登録されていなくても)、上の方法で列情報を取り出すことはできる

具体的なコードとしてはこのような感じです:
var PG = require( 'pg' );
var pg = new PG.Pool({
  connectionString: "postgres://user:pass@hostname:5432/db",
  idleTimeoutMillis: ( 3 * 86400 * 1000 )
});

  :
  :

if( pg ){
  var conn = await pg.connect();
  if( conn ){
    try{
      var sql = 'select * from items';
      var query = { text: sql, values: [] };
      conn.query( query, function( err, result ){
        if( err ){
          console.log( err );
        }else{
          var fields = r1.result.fields;
          var types = r1.result._types._types.builtins;
          var columns = [];
          fields.forEach( function( f ){
            var dt = Object.keys( types ).reduce( function( r, key ){
              return types[key] === f.dataTypeID ? key : r;
            }, null );
            columns.push( { column_name: f.name, type: dt } );
          });

            :
            :
        }
      });
    }catch( e ){
      console.log( e );
    }finally{
      if( conn ){
        conn.release();
      }
    }
  }
}


赤字の部分の解説をします。まず "postgres://(ユーザー名):(パスワード)@(PostgreSQL サーバー名):(ポート番号)/(DB名)" というフォーマットの接続文字列を使ってデータベースに接続します(正しく接続できるのであれば、このフォーマットである必要はありません)。 そして接続後に "select * from items" というシンプルな SQL を実行して、結果を result という変数で受け取ります。この SQL 実行結果(レコード情報)自体は result.rows という属性に配列形式で格納されているのですが、今回ここは使いません。

この SQL を実行することにより、指定したテーブル(今回の場合は items)の列名とデータ型IDの情報が result.fields に、データ型IDとデータ型の関係を示す表が result._types._types.builtins に格納されているはずです。これらを取り出し、各列のデータ型を ID ではなく文字列に変換しなおして、最終的に columns という配列変数に記録しています。

この columns の実行結果を参照すると、このような値になっているはずです:
    [
      {
        "column_name": "id",
        "type": "VARCHAR"
      },
      {
        "column_name": "name",
        "type": "VARCHAR"
      },
      {
        "column_name": "price",
        "type": "INT4"
      },
      {
        "column_name": "body",
        "type": "TEXT"
      },
      {
        "column_name": "datetime",
        "type": "TIMESTAMP"
      }
    ]

"integer" 型が、より正確な "INT4" という型になっていたりはしますが、当初取得したかった列の定義情報を取得することができました。なお、この方法であれば SQL の実行結果(result.rows)そのものを参照しているわけではないため、実行結果が0件であっても(レコードがまだ1件も登録されていない場合でも)実行できるようです。

サンプルソースコードはこちらからどうぞ:
https://github.com/dotnsf/pg_fieldtype


(2023-12-18 追記)
ちなみに MySQL の場合、その名もズバリの mysql ライブラリを使うのが定番だと思ってますが、こちらの場合はテーブル一覧("show tables")もテーブル定義("desc (テーブル名)")も、CLI で使う命令文をそのまま利用して取得することができるので、深く考えなくてもよいのでした。




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 回くらい試してもいいのかな?

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

フロントエンドフレームワークではない、サーバーサイドアプリとしての 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 では未実装らしくエラーになってしまいました。もしかするとこちらにも同様の互換関数があるのかもしれませんが未調査です。




自分が作るアプリの中でユーザー認証/登録といった機能が必要な場合、自分の場合は自作することはほとんどなく、IDaaS 等と呼ばれている SaaS 系専用サービスを使って実装しています。具体的には(資料が充実しているという理由ですが) Auth0AppID を使うことが多いです。単なるユーザー認証だけでなく、(実装する場合に面倒な)オンラインサインアップやパスワード再設定、パスワード忘れ時のリセット機能などをサービスが既にセキュアな形で組み込まれていて、自分ではほぼ実装を意識することなく使えて便利※です。

※もう少しちゃんと説明すると、Auth0 の場合であれば認証自体は OAuth を使って auth0.com で行い、認証情報が正しければコールバックで受け取った情報を使ってアクセストークンを取得できる、というロジックを簡単に実装できます。


この Auth0 を使ってアプリを作った場合、ユーザー名(表示名)やそのアイコン画像は(特定のルールに基づいて)登録時に決まります。ユーザー名はともかく、特にアイコン画像は自動生成されたものではなく自分で気に入っているものを使いたいと感じることが多いと思っています。が、現状ユーザー本人が直接変更することはできません。登録時に決まったものを使い続ける、ということになるので、こだわりを持ったユーザー名やユーザーアイコンへの変更ができないことを問題と感じていました。

で、Auth0 を調べたところ「変更用の API 自体は提供されている」ことが分かりました。要はユーザーが直接変更する機能は提供されていないが、その機能を自分で自分のアプリ内に作る方法は提供されている、ということになります。これを Node.js で早速試してみたのが今回のブログエントリのテーマです。


【プロフィール画像変更 API】
例えばプロフィール画像を変更する API 自体はこのように提供されています(下図は Node.js のサンプル):
2023022001


この上図で言うところの USER_ID 部分に画像を変更したいユーザーの ID(Auth0 上での User.id)を指定し、また変更後の画像 URL を picture の値として指定した上で上述の REST API を実行すればよい、ということになります。

特に Node.js の場合は同様の処理をより簡単に実行できるような SDK も提供されており、こちらを使った場合は ManagementClient クラスの update メソッドに必要なパラメータを渡して実行するだけです。

なおどちらの例もユーザーのアイコン画像(picture)だけを変更する例として紹介されていますが、実際には picture 以外にも nickname(表示名)や firstName, lastName など他の多くの属性を変更することも可能です。 実装そのものは後者(SDK)の方が簡単なので、以下こちらを使って説明を続けます。


【client_id と client_secret の準備】
この API を実行するには Auth0 の認証アプリを作る時と同様に client_id と client_secret (と domain)情報が必要です。ただ多くの場合、取得し直しが必要になる点に注意が必要です。以下は Auth0.com にログインし、メニューから Applications - Applications を選択した後に "Create Application" を実行した時の画面です:
2023022004


Auth0 を使って自分のアプリに認証機能を付加する場合、そのアプリの種類によって "Native(スマホ向けネイティブアプリ)", "Single Page Web Application(SPA)", "Regular Web Applications(その他のいわゆる「ウェブアプリ」)" のいずれかから1つ選んで(Auth0 の)アプリケーションを登録することが多いはずです:
2023022002


ですが、今回の機能を実現するためには "M2M(Machine to Machine)Applications" の client_id と client_secret が必要になります。そのため、まだ M2M アプリを作ったことがない場合は Machine to Machine Applications を選択して、新たに1つ追加登録してください:
2023022003


次の画面で対象 API を選択しますが、M2M アプリの場合は "Auth Management API" の1つしか選択肢がないので、これを選択します:
2023022006


最後に API の許可スコープを指定します。今回はユーザー管理機能を実装するので、検索バーに "users" と入力し、そこで見つかる全てのスコープにチェックを入れて、最後に "Authorize" ボタンをクリックするとアプリケーション登録が完了します:
2023022007


こうして Auth0.com に作成(追加)した M2M アプリケーションの Client ID、Client Secret(と Domain ですが、Domain は他のアプリ登録時に取得したものと同じはずです)が必要になります。利用時にすぐ取得できるようコピー&ペースト等で準備しておいてください:
2023022005


【実装】
ここまでの準備ができたら実装してみましょう。Node.js の場合は auth0 という便利なクライアントライブラリの SDK があるので、これを使って実装します。なお最後にサンプルアプリ・ソースのリンクを載せておくので、興味ある方はそちらをダウンロード後に実際に動作確認してみてください。

まず auth0 をインスタンス化します:
//. Auth0 Management API
var ManagementClient = require( 'auth0' ).ManagementClient;
var auth0 = new ManagementClient({
  domain: 'xxxx.auth0.com',       // domain の値
  clientId: 'client_id',          // client_id の値
  clientSecret: 'client_secret',  // client_secret の値
  scope: 'create:users read:users update:users'
});


auth0 の ManagementClient を取得し、domain, client_id, client_secret の3つの値と、スコープとして 'create:users read:users update:users' を指定してインスタンス化します(今回はプロフィール画像 URL を更新するため、これら全てがスコープとして必要になります)。繰り返しになりますが、重要なのはここで指定する client_id や client_secret は通常のネイティブアプリケーション/ウェブアプリケーションのものではなく、M2M アプリケーションとして登録したアプリケーションのものを使う必要がある、という点です。

そして実際に user_id = 'abcabc' のユーザーのプロフィール画像 URL を変更する場合は、以下のような処理を行います:
var params = { user_id: 'abcabc' };  // ユーザーIDが 'abcabc' の人が対象
var metadata = { picture: 'https://manholemap.juge.me/imgs/logo.jpg' };  // プロフィール画像を指定 URL のものに変更する
auth0.users.update( params, metadata, function( err, user ){
  if( err ){
    console.log( { err } );
        :  // エラー時の処理
  }else{
    console.log( { user } );
        :  // 成功時の処理
  }
});


実行する関数は auth0.users.update() で、この実行時パラメータとして第一パラメータに対象ユーザーの情報、第二パラメータに変更内容を指定します。実行後にエラーか成功かを識別して処理を続けます。

上の例だとプロフィール画像だけを変更していますが、複数の属性を同時に変更することも可能です。例えば以下の例は対象ユーザーのプロフィール画像(picture)と表示名(nickname) を同時に変更しています:
var params = { user_id: 'abcabc' };
var metadata = { nickname: '俺', picture: 'https://manholemap.juge.me/imgs/logo.jpg' };
auth0.users.update( params, metadata, function( err, user ){
  if( err ){
    console.log( { err } );
        :
  }else{
    console.log( { user } );
        :
  }
});


参考になれば、と思って実際に動くサンプルアプリを用意しました:
https://github.com/dotnsf/auth0-userpicture


サンプルアプリを実際に動かす場合は Auth0 に(M2M ではない通常の Web)アプリケーションも登録し、その client_id, client_secret, domain を取得し、また OAuth のコールバックURL(callback_url) を実行時の環境変数に登録する必要があります。実行時の環境変数登録時は同リポジトリ内の .env ファイルにその内容を記載することでも代替可能です。



参照していただくとわかるのですが、実際にユーザーの属性を変更しようとすると対象ユーザーの ID が必要になります。ユーザー ID は OAuth でログイン後にわかるので、実際のアプリケーションでは普通の(通常のウェブの)アプリケーションと、ユーザー管理用の M2M アプリケーションの2つを登録する必要があり、それぞれの client_id と client_secret の両方を使って実装することになります。詳しくはソースコード内を参照ください。


このページのトップヘ