URLPattern を Deno で試してみる 🦕

f:id:notfounds8080:20210917235329p:plain
Photo by Andrew Ridley on Unsplash

こんにちは、最近自分の中で Deno が熱いです。 Deno は JavaScript / TypeScript のランタイムであり、Node.js の作者である Ryan Dahl 氏が 2018年に行った「Node.jsに関する10の反省点」という発表の中で発表されました。
強力エコシステムや Permission 設定によるセキュリティの強化、ブラウザとの互換性など魅力的です。 あとロゴの恐竜がかわいいです(大事)。

deno.land

そんな訳で今回は Deno で遊んでみようと思います

はじめに

先日 9/14 に Deno の v1.14 がリリースされました🎉 (Release Notes)
日本語だとこちらの記事がわかりやすいです。
zenn.dev

自分が気になったトピックはこの辺です。

  • deno lintdeno fmt のスタイルを設定できるようになった
  • URLPattern が実装された
  • URL の parse が3倍速くなった

また、同日に Deno の標準ライブラリである deno_std の v0.107.0 もリリースされました 🎉
こちらは collections モジュールの機能追加と http モジュールの改良が主な変更点です。

Deno v1.13 で HTTP Server のネイティブ実装が stable になりました。これによって std/http は削除される予定*1だったのですが、今回ネイティブ実装を使うようになったため std/http は残されるようです。*2 また、ネイティブ実装を利用することでパフォーマンスが大幅に改善しています。

今回のリリースでは Deno で Web Server を実装するにあたって嬉しい内容が多いなという感想です。
この記事では新たに追加された URLPattern API を用いて、外部のライブラリに頼らずルーティングを行う例を示します。
簡単な Web Server であれば標準ライブラリだけで割と書けるようになっています。

URLPattern とは

web.dev

まず URLPattern とは何かについて紹介したいと思います。
URLPattern は新しめの WebAPI で、URL によるルーティングパターンを標準化するためのものです。 ルーティングで使われるパターンには明確な標準はありませんが正規表現ワイルドカード、名前付きのトークングループなどがよく使われます。 URLPattern で構文を定義することによって、ルーティングパターンの再発明を避けることが目的です。

いくつか例を挙げると次のようなパターンが利用できます。
Ruby on Rails や Express などのフレームワークを使ったことのある方にとっては馴染みのある表記ではないでしょうか。

  • 文字列の完全一致
  • /posts/* のように任意の文字に一致させるためのワイルドカード
  • /books/:idのように一致したURLの一部を抽出するために使用する名前付きグループ
  • /books{/old}?のようにパターンの一部をオプションにする、もしくは複数回一致させるために使用できる非キャプチャグループ(Non-capturing groups)
  • /books/(^\d)のように任意の正規表現による一致

引用: https://pr8734.content.dev.mdn.mozit.cloud/en-US/docs/Web/API/URLPattern

引用元のMDN(draft?)で URLPatter の Browser Compatibility を見てみると利用できるブラウザは現時点(2021/09/17)で Chrome、Edge だけようです。
ちなみに表中にはないですが、Node.js ではまだ利用できないようです。

url_pattern_browser_compatibility
URLPattern - Browser compatibility | Web APIs | MDN

使い方

ここでは簡単に URLPattern の使い方を説明します。
URLPattern はその名の通り(判定したい) URL のパターンをプロパティとして持ち、正規表現を扱う RegExp のように exectest といった実際にパターンマッチを行うためのメソッドを持つクラスです。

const pattern1 = new URLPattern("https://example.com/users/*");
const pattern2 = new URLPattern("https://example.com/users/:id");
const pattern3 = new URLPattern({ pathname: "/users/:id" });

console.log(pattern1.test("https://example.com/users"));     // false
console.log(pattern1.test("https://example.com/users/123")); // true

console.log(pattern2.exec("https://example.com/users"));                      // null
console.log(pattern2.exec("https://example.com/users/123")?.pathname.groups); // { id: "123" }
console.log(pattern3.exec("https://example.com/users/123")?.pathname.groups); // { id: "123" }

test メソッドは、与えられた文字列がパターンにマッチするかを判定し boolean を返します。
exec メソッドは、与えられた文字列がパターンにマッチするかを判定しマッチした場合は URLPatternResult というオブジェクトを返し、マッチしなかった場合は null を返します。
ここで得られる URLPatternResult は pathname.groups に名前付きグループにマッチした部分を抽出しているのですが Record<string, string> 型となっています。

他にもたくさんのパターンが利用できますが、ここでは省略いたします。

利用例

次に Deno でシンプルな Web Server を立て、そこで URLPattern を利用してみたいと思います。

Deno 公式のマニュアル を見てみると Deno 本体にある HTTP API を用いたサーバーの実装例標準ライブラリ std/http を用いたサーバーの実装例を見つけることができます。
今回はせっかくなのでネイティブ実装の API を使うようになって速くなった std/http を使ってみようと思います。

一旦公式マニュアルのものをそのまま引用します。

webserver.ts

import { listenAndServe } from "https://deno.land/std@0.107.0/http/server.ts";

const addr = ":8080";

const handler = (request: Request): Response => {
  let body = "Your user-agent is:\n\n";
  body += request.headers.get("user-agent") || "Unknown";

  return new Response(body, { status: 200 });
};

console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await listenAndServe(addr, handler);

ルーティングなど無いシンプルなサーバーです。
deno run --allow-net webserver.ts で起動できます。
適当な path にアクセスすると Request Header から User-Agent を取得し返します。

これをベースに URLPattern を使ってみようと思います。

webserver_with_routing.ts

import { listenAndServe } from "https://deno.land/std@0.107.0/http/server.ts";

const addr = ":8080";

const patternsToHandlers = new Map([
  [{ pathname: "/" }, rootHandler],
  [{ pathname: "/ping" }, pingHandler],
  [{ pathname: "/users/:id" }, userHandler],
]);

const routingMap = new Map();
for (const [pathPattern, handler] of patternsToHandlers) {
  const compiledPattern = new URLPattern(pathPattern);
  routingMap.set(compiledPattern, handler);
}

const routingHandler = (request: Request): Response => {
  for (const [pattern, handler] of routingMap) {
    const matched = pattern.exec(request.url);
    if (matched) {
      return handler(request, matched);
    }
  }

  return new Response(null, { status: 404 });
};

console.log(`HTTP webserver running. Access it at: http://localhost${addr}/`);
await listenAndServe(addr, routingHandler);

// request handlers
function rootHandler(req: Request, matchRes: URLPatternResult | null) {
  return new Response("Hello, World!", { status: 200 });
}

function pingHandler(req: Request, matchRes: URLPatternResult | null) {
  return new Response("pong", { status: 200 });
}

function userHandler(req: Request, matchRes: URLPatternResult | null) {
  return new Response(`User ID: ${matchRes?.pathname.groups.id}`, { status: 200 });
}

いくつか新しい path を生やしてみました。
deno run --unstable --allow-net webserver_with_routing.ts で起動できます。
これは次のような挙動をします。

  • / にアクセスしたときは "Hello, World!" を返す
  • /ping にアクセスしたときは "pong" を返す
  • /users/:id にアクセスしたときは UserID: <id> を返す( は path param)
  • 上記以外の場合は status code 404 を返す

handler のインターフェースなど改善したい気持ちはありますが、基本的なルーティングを行うことができました。

おまけ:簡易 Router の実装

上で実装した例では HTTP Method による分岐は実装しておらず、それぞれの handler 内部で実装する必要がありました。
ここでは簡易的なルーティングを行うクラスを実装し、HTTP Method による分岐も実装してみたいと思います。

以下のような Router クラスを実装します。
routers オブジェクトでは HTTP Method ごとの routing map を持つようにし getpost などのインスタンスメソッド経由でルーティングを定義します。

router.ts

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
type Handler = (req: Request, urlRes: URLPatternResult | null) => Response;
type RoutingMap = Map<URLPattern, Handler>;

export class Router {
  routers: Record<HTTPMethod, RoutingMap>;
  constructor() {
    this.routers = {
      'GET': new Map<URLPattern, Handler>(),
      'POST': new Map<URLPattern, Handler>(),
      'PUT': new Map<URLPattern, Handler>(),
      'PATCH': new Map<URLPattern, Handler>(),
      'DELETE': new Map<URLPattern, Handler>(),
      'HEAD': new Map<URLPattern, Handler>(),
    };
  }

  get(pathname: string, handler: Handler) {
    this.routers["GET"].set(new URLPattern({ pathname }), handler);
    return this;
  }

  post(pathname: string, handler: Handler) {
    this.routers["POST"].set(new URLPattern({ pathname }), handler);
    return this;
  }

  put(pathname: string, handler: Handler) {
    this.routers["PUT"].set(new URLPattern({ pathname }), handler);
    return this;
  }

  patch(pathname: string, handler: Handler) {
    this.routers["PATCH"].set(new URLPattern({ pathname }), handler);
    return this;
  }

  delete(pathname: string, handler: Handler) {
    this.routers["DELETE"].set(new URLPattern({ pathname }), handler);
    return this;
  }

  head(pathname: string, handler: Handler) {
    this.routers["HEAD"].set(new URLPattern({ pathname: pathname }), handler);
    return this;
  }

  route(req: Request): Response {
    try {
      for (const [pattern, handler] of this.routers[req.method as HTTPMethod]) {
        const matched = pattern.exec(req.url);
        if (matched) {
          return handler(req, matched);
        }
      }
      return new Response(null, { status: 404 });
    } catch(e) {
      console.error(e);
      return new Response(null, { status: 500 });
    }
  }
}

これを使うと以下ようにルーティングを記述することができます。

server.ts

import { listenAndServe } from "https://deno.land/std@0.107.0/http/server.ts";
import { Router } from "./router.ts";

const addr = ":8080";
const router = new Router()
  .get("/users/:id", showUserHandler)
  .post("/users", createUserHandler)
  .put("/users/:id", updateUserHandler)
  .patch("/users/:id", updateUserHandler)
  .delete("/users/:id", deleteUserHandler);

console.log(`HTTP webserver running. Access it at: http://localhost${addr}/`);
await listenAndServe(addr, (req) => router.route(req));

// request handlers
function showUserHandler(req: Request, urlRes: URLPatternResult | null) {
  return new Response(`Get the user: ${urlRes?.pathname.groups.id}`, { status: 200 });
}

function createUserHandler(req: Request, urlRes: URLPatternResult | null) {
  return new Response(`Create a user`, { status: 201 });
}

function updateUserHandler(req: Request, urlRes: URLPatternResult | null) {
  return new Response(`Update the user: ${urlRes?.pathname.groups.id}`, { status: 201 });
}

function deleteUserHandler(req: Request, urlRes: URLPatternResult | null) {
  return new Response(`Delete the user: ${urlRes?.pathname.groups.id}`, { status: 201 });
}
~/URLPattern ❯❯❯ curl "localhost:8080/ping"
pong%
~/URLPattern  ❯❯❯ curl -X"GET" "localhost:8080/users/123"
Get the user: 123%
~/URLPattern  ❯❯❯ curl -X"POST" "localhost:8080/users"
Create a user%
~/URLPattern ❯❯❯ curl -X"PUT" "localhost:8080/users/123"
Update the user: 123%
~/URLPattern ❯❯❯ curl -X"PATCH" "localhost:8081/users/123"
Update the user: 123%
~/URLPattern ❯❯❯ curl -X"DELETE" "localhost:8080/users/123"
Delete the user: 123%
~/URLPattern ❯❯❯ curl -X"GET" -s -o /dev/null -w "%{http_code}" "localhost:8080/"
404%

まとめ

Deno v1.14 で追加された URLPattern という WebAPI の紹介を行い、Deno で簡単なルーティングを行う例を示しました。
URLPattern 自体によってルーティング処理がすごく書きやすくなった!という印象はないですが、ルーティングのパターンを統一することの意義は大きいと思います。

また、最後に実装した Router では HTTP Method と URL のパターンによるルーティングを行えるようにしました。 Router を自分で実装するならどうするか?という部分は面白かったですが、 エラーハンドリングやロギングなどを考えると普通に oak などのフレームワークを使いたくなりました 😇

フレームワークを使って開発をしていると URLPattern API を直接触ることはなかなか無いかもしれませんが、覚えているといざというとき役に立つかもしれないですね。

References

*1:https://deno.com/blog/v1.13#stabilize-native-http-server-api では"If you are currently using std/http we encourage you to upgrade to the native HTTP server. std/http will be available in std for a few more releases, but will be removed soon. " と書かれていて、この時点では削除する予定でした

*2:https://github.com/denoland/deno_std/discussions/1034