Deno.serveを使った中継用APサーバー

に公開

はじめに

Deno.serve を使った(ローカルで動かすこと前提の)中継用のアプリケーションサーバーの話です。「サードパーティ Web API(の超基本)」に書いていたものを(元チャプターは Hono を使ったものに変えた上で)単体記事に移行しました。OJK の担当している授業の参考記事です。

「サードパーティ Web API(の超基本)」では、ブラウザーから直接サードパーティ API に fetch することを前提としていましたが、セキュリティ(CORS)の関係上、ブラウザーから直接 fetch できないことがあります。そこで、ローカル(自分の PC)に fetch を中継するアプリケーションサーバー(中継用 AP サーバー)を立てます。この記事では、その AP サーバーを、Deno 標準の機能で作る方法について説明しています。

CORS の概説や中継用 AP サーバーについては以下のチャプターを参照してください。

https://zenn.dev/ojk/books/intro-to-webapi/viewer/api-apserver#オリジン間リソース共有(cors)

Deno のインストール

Deno は JavaScript の “ランタイム” と呼ばれるもので、ブラウザー組み込み言語である JavaScript をブラウザー以外で使えるようにしたものです(公式サイト)。まずは自分の PC にインストールする必要があります。

VSCode で空のフォルダーを開きます。VSCode の[ターミナル]メニューから「新しいターミナル」という項目を選んでください。エディターの下にターミナルが出現したと思います。ターミナル右上のメニュー(アイコンが並んでいる)の左端にターミナルの種類が表示されています。Windows なら「pwsh」、Mac なら「zsh」になっているのではないかと思います。もし Windows で「cmd」となっていたら、その右側にある「+」ボタン右の ▽ から種類の一覧を表示して「PowerShell」を選んでください。

このターミナルにコマンドを打ち込んで実行する(Enter キーを押す)ことにより、Deno をインストールすることができます。ターミナルの左端に最初から表示されている文字列のことをプロンプトといいます。プロンプトは常に表示されていて消せません。プロンプトの右側(カーソルのあるところ)をクリックすると文字が打ち込めると思います(試しに打ち込んだら消しておいてください)。

では、それぞれ対応する OS のコードをターミナルにコピペして、Enter キーを押します。

Windows(PowerShell)
iwr https://deno.land/install.ps1 -useb | iex
Mac、WSL
curl -fsSL https://deno.land/install.sh | sh

いろいろと表示されますが、そのまましばらく待ちます。「Deno was installed successfully」などとそれらしいメッセージがターミナルのどこかに表示されていたら成功です。

Mac の場合の追加作業

Mac の場合、以下のようなメッセージが表示されているかもしれません。「***」の部分はあなたの Mac のユーザー名が入っていると思います。バージョンによっては「.zshrc」という部分が「.bash_profile」かもしれませんが、適宜読み替えてください。

Manually add the directory to your $HOME/.zshrc (or similar)
 export DENO_INSTALL="/home/***/.deno"
 export PATH="$DENO_INSTALL/bin:$PATH"

これはホームディレクトリ(/home/ユーザー名)というフォルダの下にある「.zshrc」というファイルにこの 2 行を追加しなさいということです。ターミナルで以下のコマンドを実行すると VSCode の新しいウィンドウで「.zshrc」という名前のファイルが開きます。

code ~/.zshrc

ターミナルに表示されている「export」で始まる 2 行をコピペして保存してください。もし「(管理者)権限がない」といったメッセージが表示される場合は sudo code ~/.zshrc と sudo を付けてコマンド実行してください(※そのユーザーのパスワードが求められます)。

ファイルを保存できたら、ターミナルに以下のコマンドをコピペして実行してください。あとは本文の手順どおりに進められると思います。

source ~/.zshrc

なお、これは Deno のインストール直後に 1 度だけ必要となる作業です。

試しに、ターミナルに deno --version と打ち込んで Enter キーを押してみてください(一文字でも間違えると失敗するので慎重に)。それらしい表示がされれば成功です。タイプミスもないのにエラーが表示されたら、VSCode を再起動してから再度ターミナルを開いて試してみてください。

雛形コード

クライアントサイド(index.html)とサーバーサイド(server.js)の雛形コードを以下に示します。ユーザー登録不要のサードパーティ API で CORS に引っかかるものが見つからなかったので、ブラウザーから直接アクセスできてしまうのですが「郵便番号検索 API」を使います。

VSCode から各ファイルを新規作成して内容をコピペしてください。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>クライアントサイド</title>
</head>

<body>
  <p>
    <button>データ取得</button>
  </p>
</body>

<script>
  document.querySelector('button').addEventListener('click', fetchData);

  // 本文中ではこの関数の中のみ掲載
  async function fetchData() {
    const url = 'http://localhost:8000';
    const res = await fetch(url);
    const obj = await res.json();
    console.log(JSON.stringify(obj, null, 2));
  }
</script>
</html>
server.js
Deno.serve(async () => {

  // サードパーティ APIからリソースを取得する
  const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060`;
  const res = await fetch(url);
  const obj = await res.json();
  // console.log(JSON.stringify(obj, null, 2)); // ターミナルに表示される

  // サードパーティーAPIから受け取ったデータをクライアント(index.html)に送り返す
  const body = JSON.stringify(obj);
  return new Response(body, {
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Access-Control-Allow-Origin': '*'
    }
  });
});

Deno で「server.js」を実行します。オプションの「-A」を付けないと正しく動かないので注意してください。

deno run -A server.js

ターミナルで上記のコマンドを実行(コピペ & Enter キー)すると、しばらくのあいだ必要なライブラリをダウンロードしている様子がターミナルに表示されます。これは初回起動時のみです。
それが終わると(あるいは 2 回目以降はすぐに) 「Listening on http://localhost:8000/」 と表示されるかと思います。これで自前の AP サーバーが立ち上がりました。

Live Server を使って index.html をブラウザーで開き、[データ取得]ボタンを押ししてみてください。以下のように(ブラウザーの)コンソールに郵便番号が表示されるはずです。

AP サーバーの停止

ターミナルでコマンドを実行すると、ターミナルにはプロンプトが表示されなくなり、カーソルがなくなって文字入力を受け付けない(もしくは反応しない)状態になります。確認用の Hello World プログラムは実行されるとすぐに終了しましたが、AP サーバーは動き続けるからです。

サーバープログラムを終了させるには、ターミナルにフォーカスを当てた上で、Ctrl-C(Ctrl キーを押しながら'C'キーを押す)で停止します。試しに 1 度やってみてください。
クライアントサイドとは異なり、サーバーサイドでは script.js を書き換えるたびにサーバーの再起動が必要になります。Ctrl-C には慣れておいてください。

ちなみに、server.js に記述された console.log は(ブラウザーのコンソールではなく)VSCode のターミナルに出力されます。試しにサーバーを停止して、さきほどのサンプルコードの console.log のコメントアウトを外し、サーバーを再度実行してみてください。

中継プログラムコードの解説

ここで示したサンプルコードを改造して好きなサードパーティ API を使えるように、その内容を理解しておきましょう。

クライアントサイド

クライアントサイド(index.html)は AP サーバーに GET リクエストを送っているだけです。雛形では JSON で受け取ったレスポンスを console.log しているだけです。

index.html
async function fetchData() {
  const url = 'http://localhost:8000';
  const res = await fetch(url);
  const obj = await res.json();
  console.log(JSON.stringify(obj, null, 2));
}

クライアントサイドから GET リクエストのクエリーを送る方法や POST リクエストを送る方法はのちほど説明していきます。

サーバーサイド

サーバーサイド(server.js)については上から順番に説明します。

まず、全体が Deno というオブジェクトの serve メソッドの呼び出しになっていることがわかるでしょうか。Deno というオブジェクトはどこにも宣言していませんが、Deno のプログラムでは最初から使えます。

server.js(大枠)
Deno.serve(...);

serve メソッドの引数は、次のような無名関数(アロー関数式)になっています。addEventListener のような感じですね。async が付いているのは、無名関数の中で await を使っているからです。

Deno.serve(async () => { ... });

Deno.serve メソッドはウェブサーバーを起動するメソッドで、ローカルで動かした場合は http://localhost:8000 という URL になります。この URL にクライアント(ブラウザー)からアクセスがあると、引数で指定された無名関数が実行されます。

次に、無名関数の中をみていきましょう。

前半部分は、利用したいサードパーティ API のエンドポイントに対して fetch しています。クエリーはひとまず URL の文字列にベタ書きにしてあります。

server.js(前半)
// サードパーティAPIからリソースを取得する
const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060`;
const res = await fetch(url);
const obj = await res.json();

後半部分は、クライアントサイドに返す Response オブジェクトを生成して return しています。

server.js(後半)
// サードパーティーAPIから受け取ったデータをクライアント(index.html)に送り返す
const body = JSON.stringify(obj);
return new Response(body, {
  headers: {
    'Content-Type': 'application/json; charset=utf-8',
    'Access-Control-Allow-Origin': '*',
  }
});

後半部分を分解してみていきましょう。
まず、最初の 1 行は(リソースの入った)オブジェクトを文字列に変換しています。今回の方法ではデータを文字列で送り返す必要があるためです。

const body = JSON.stringify(obj);

続くコードは複数行に分かれていますが、実際は次の 1 行のコードです。

return new Response(body, { オプション });

これは、この無名関数の return 文です。何を return しているかというと、Response コンストラクターで生成された Response オブジェクトです。クライアント(客)が要求(Request)して、サーバー(給仕)が返答(Response)するわけですね。

Response コンストラクターの第 1 引数はデータで、第 2 引数はオプションです。オプションでは、headers プロパティで レスポンスヘッダー を指定することができます。

server.js
headers: {
  'Content-Type': 'application/json; charset=utf-8',
  'Access-Control-Allow-Origin': '*'
}

レスポンスヘッダーでは、Content-Type プロパティで JSON 形式を指定していることに加えて、'Access-Control-Allow-Origin' プロパティに「 * 」が指定されています。これは、「オリジン間リソース共有(CORS)」の設定で、すべてのオリジンからのアクセスを許可しています。

なお、今回はローカルだけで動かす前提にしているので「すべてのオリジンからのアクセスを許可」していますが、クライアントサイドのファイルを Github Pages などにアップロードしてインターネットから誰でもアクセスできるようにするときには、許可するドメインを Github Pages などの URL に限定してください。

'Access-Control-Allow-Origin': 'https://ojklab.github.io',

改めて全体を眺める

以上、index.html と server.js の内容を確認してきましたが、全体が以下の図のようになっていることが把握できたでしょうか。

ブラウザーから直接サードパーティ API に fetch するのではなく、ブラウザー(index.html)から自前の AP サーバー(server.js)へ一旦 fetch し、その AP サーバーがサードパーティ API への fetch を中継しています。

クライアントサイドのfetch
// index.html → server.js にリソースを要求
const res = await fetch('http://localhost:8000');
サーバーサイドのfetch
// server.js → サードパーティ APIにリソースを要求
const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060`;
const res = await fetch(url);

server.js はブラウザー上で実行されているプログラムではないので CORS の制約を受けません。また、index.html から server.js への fetch では、サーバーサイド(server.js)で CORS を「'Access-Control-Allow-Origin': '*'」と設定しているので CORS には引っ掛からない…というわけです。

本チャプターの例では AP サーバー(server.js)もクライアントプログラム(index.html)もローカルで動かしていますが、この仕組みはサーバー・クライアントをインターネット上で動かしても同じように動作します。クライアントは Github Pages、サーバーは Deno Deploy を使えば無料で試すことができます(Deno deploy についてはこちらの記事もご参考に)。

クライアントサイドからクエリーを受け渡す

さきほどの例では GET リクエストのクエリーを中継用 AP サーバーのほうで埋め込んでいましたが、ウェブアプリケーションであれば、ブラウザーからユーザーが入力した情報に基づいてサードパーティ API とやりとりしたいでしょう。

ということで、ここではクライアント(index.html)から中継用 AP サーバー(server.js)へクエリーを受け渡す例をみておきます。

題材は前回のチャプターと同じく「郵便番号検索 API」を使います。ブラウザーのフォームから入力された郵便番号を API へのクエリーに加えるように、前回のサンプルコードを改変します。

クライアントサイド(クエリーの送信)

HTML に input 要素を追加します。確認のたびに打ち込むのが面倒なので、初期値は value 属性で指定しています。

index.html(HTML部分)
<p>
  <label>郵便番号: <input value="7830060" /></label>
  <button>データ取得</button>
</p>

クライアントサイド(index.html)の JavaScript では、input 要素の値(value プロパティ)を取得し、URLSearchParams オブジェクトを介して fetch 関数に渡します。クエリー 1 個だけなので、テンプレートリテラルや文字列の足し算を使ってももちろん構いません。

index.html(JS部分)
 async function fetchData() {
  // input要素の値を取得して、URLSearchParamsオブジェクトを生成
  const input = document.querySelector('input').value;
  const query = new URLSearchParams({ zipcode: input });

  // クエリー付きでGETリクエスト
  const url = 'http://localhost:8000?' + query;
  const res = await fetch(url);

  const obj = await res.json();
  console.log(JSON.stringify(obj, null, 2));
 }

クライアントサイドの修正だけならば、AP サーバーは動かしたまま、ブラウザーを再読み込みすれば適用されます。server.js でクエリーを受け取らなくてもエラーにはなりませんから、サーバーを停止していなかった人はブラウザーを再読み込みして確認しましょう(まだ何も変わりませんが)。
サーバーを止めていた人は再度動かします。

deno run -A server.js

サーバーサイド(クエリーの受け取り)

次に、AP サーバー(server.js)を修正します。まずは全体を先に示しておきます。

server.js(全体)
Deno.serve(async (req) => {
  // クライアントからのリクエストのクエリーを取得する
  const { searchParams } = new URL(req.url);

  // 特定のクエリーの値を取得する
  const zipcode = searchParams.get('zipcode');

  // サードパーティー製のAPIのURLを組み立てる(URLSearchParamsも使える)
  const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`;

  /* 以下、前回のチャプターの内容と同じ */

  // サードパーティAPIからのレスポンスを処理する
  const res = await fetch(url);
  const obj = await res.json();

  // サードパーティーAPIから受け取ったデータをクライアント(index.html)に送り返す
  const body = JSON.stringify(obj);
  return new Response(body, {
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Access-Control-Allow-Origin': '*'
    },
  });
});

以下、分解してみていきます。

まず、1 行目では、Deno.serve メソッドに指定された無名関数に引数 req が追加されています。この引数 req の中身は Request オブジェクト[1]で、クライアントから GET リクエストで受け取った情報(クエリーだけじゃない)がすべて入っています。

Deno.serve(async (req) => { ... }

次に、GET リクエストの情報からクエリーの部分だけを次のようにして抽出します。

const { searchParams } = new URL(req.url);

右辺の URL コンストラクターについての説明は割愛します。
左辺の const { searchParams } = の部分は分割代入といい、URL コンストラクターの戻り値(=オブジェクト)の中から、searchParams プロパティの値のみを(同名の変数に)取り出しています(参照)。
※つまり、「searchParams」という変数名は自分で好きに決められないということです。

変数 searchParams の中身は URLSearchParams オブジェクトです。個々のクエリーの値は、get メソッドによって取得します。今回は「zipcode=7830060」などとして送られてきているので、searchParams.get('zipcode') とします。

server.js
const zipcode = searchParams.get('zipcode');

あとはこれまでのやり方と変わりません。サードパーティ API に送るクエリー付きの URL を組み立てて、fetch します。

server.js
const url = `https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipcode}`;
const res = await fetch(url);
...

server.js のほうは修正したらサーバーを再起動する必要があります。サーバーの停止は、ターミナルにフォーカスを当てた上で、キーボードから Ctrl-C でしたね。サーバーを停止すると、ターミナルの画面にプロンプトが表示され、再びキー入力を受け付けるようになります。

サーバーを終了したら、再び Deno のコマンドを実行してサーバーを起動します。以下のコマンドを再度コピペしてもよいですが、カーソルキーの上矢印を押すと過去のコマンドの履歴が出てきます。直前に実行しているはずですから、上矢印を 1 回押せばコマンドが表示されるはずです。

deno run -A server.js

サーバーを再起動したら、念のためブラウザーも再読み込みしてください。試しに、自分の家の郵便番号をテキストフォームに入力して[リソース取得]ボタンを押してみましょう。

クライアントサイドから POST で画像データを送る

クライアント(index.html)からサードパーティ API に画像データを大きいときは、中継用 AP サーバー(server.js)に GET ではなく POST リクエストで画像データを送りたいときがあるかもしれません。

画像データの送り方にはフォームデータと JSON の 2 種類がありますが、JSON にするとプリフライトリクエストの処理が必要になってサーバーサイドが複雑になるので、ここではフォームデータで送る場合についてのみ例を示しておきます。

クライアント

クライアントサイド(index.html)の HTML 側は input[type="file"] で画像ファイルを選ばせます。画像プレビューはありませんので、必要ならば FileReader オブジェクトで画像データを取得してください(参考)。

index.html(HTML部分)
<body>
  <p>
    <input type="file" accept=".png, .jpg, .jpeg" />
  </p>
  <p>
    <button>送信</button>
  </p>
</body>

クライアントサイドの JS 側は次のようになります。中継用 AP サーバーにはフォームデータで画像データを送ります。

index.html(JS部分)
<script>
  document.querySelector('button').addEventListener('click', fetchData);

  async function fetchData() {

    // 画像ファイルの読み込み
    const file = document.querySelector('input').files[0];
    if (!file) {
      window.alert('画像ファイルを選んでください');
      return;
    }

    // フォームデータの作成(サードパーティAPIの仕様に従う)
    const msgBody = new FormData();
    msgBody.append('image', file, file.name); // 画像データの添付
    msgBody.append('author', 'ojk'); // その他、適当なデータ

    // POSTリクエスト
    const url = 'http://localhost:8000';
    const res = await fetch(url, {
      method: 'POST',
      body: msgBody
    });

    // レスポンスの処理
    const obj = await res.json();
    console.log(JSON.stringify(obj, null, 2));
  }
</script>

サーバーサイド

サーバーサイド(server.js)は GET リクエストの処理から POST リクエスト(フォームデータ)の処理に変わります。

先頭は同じです。変数 req で Request オブジェクトを受け取ります。

Deno.serve(async (req) => { ... }

POST リクエストのフォームデータは Request オブジェクトの formData メソッドを使って受け取ります。formData メソッドは非同期関数なので await が必要です。各データの取得は FormData オブジェクト(変数 data)の get メソッドを使います。画像データもテキストデータと同じ形で取得できます。

POSTリクエストのデータの受け取り
const data = await req.formData();
const file = data.get('image');    // 画像データ
const author = data.get('author'); // 適当なデータ

あとはサードパーティ API に送るデータを FormData オブジェクトで詰め直して送ります。ここではサードパーティ API として「HTTPBin」を使っています。

// フォームデータを作成する(サードパーティAPIの仕様に従う)
const msgBody = new FormData();
msgBody.append('image', file, file.name); // 画像データの添付
msgBody.append('author', author); // その他、適当なデータ

// サードパーティAPIへPOSTリクエストする
const url = 'https://httpbin.org/post';
const res = await fetch(url, {
  method: 'POST',
  body: msgBody,
});

「HTTPBin」は送った情報をそのまま JSON で返してきますが、画像データは Base64 形式に変換されます。クライアントサイドにそのまま返します(※実際には利用するサードパーティ API の仕様に従って処理してください)。

// サードパーティAPIからのレスポンスを処理する
const obj = await res.json();
const body = {
  image: obj.files.image,
  author: obj.form.author,
};

最後はクライアント(index.html)に JSON でレスポンスを返して終わりです。

// サードパーティーAPIから受け取ったデータをクライアント(index.html)に送り返す
const body = JSON.stringify({ status: 'ok' });
return new Response(body, {
  headers: {
    'Content-Type': 'application/json; charset=utf-8',
    'Access-Control-Allow-Origin': '*',
  },
});

全体のコードを示します。

server.js(全体)
Deno.serve(async (req) => {

  // クライアントからのPOSTリクエストのデータを受け取る
  const data = await req.formData();
  const file = data.get('image');    // 画像データ
  const author = data.get('author'); // 適当なデータ

  // フォームデータを詰め直す(変数reqはそのまま送れない)
  const msgBody = new FormData();
  msgBody.append('image', file, file.name); // 画像データの添付
  msgBody.append('author', author);         // その他、適当なデータ

  // サードパーティAPIへPOSTリクエストする
  const url = 'https://httpbin.org/post';
  const res = await fetch(url, {
    method: 'POST',
    body: msgBody
  });

  // サードパーティAPIからのレスポンスの処理(HTTPBinはJSONで返ってくる)
  const obj = await res.json();
  const body = {
    image: obj.files.image,
    author: obj.form.author
  };

  // サードパーティーAPIの結果をクライアントサイド(ブラウザー)に返す
  return new Response(JSON.stringify(body), {
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Access-Control-Allow-Origin': '*'
    },
  });
});

さいごに

サーバーサイドに Deno の標準機能である server メソッドを使う方法で解説しました。実際には Hono などの使いやすいライブラリで書くのが楽ですし、AP サーバーと同時にウェブサーバーを動かしてしまえばクライアントサイドと AP サーバーの間で CORS の制約にも引っかかりません。「サードパーティ Web API(の超基本)」ではそうしています。ただ、Hono を使ってしまうと具体的に何をしているか見えにくくなってしまうので、この記事を残しました。

脚注
  1. 正確にはブラウザー API の Request オブジェクトを Deno が模倣したものです。後述の URLSearchParams オブジェクトも同様です。Deno が意図してインタフェースを同じにしています。 ↩︎

Discussion