arrow一覧に戻る

2021.09.30

JSランタイムエンジンDenoを触ってみる

みなさんDenoをご存知でしょうか。

DenoとはNode.jsの開発に関わっていたRyan Dahl氏がNode.jsプロジェクトで問題と感じたことを解消するために開発されたJavaScript/TypeScriptランタイムエンジンです。

ここではNode.jsにどのような課題があり、それをRyan Dahl氏はDenoでどう解決したかと、実際にDenoを使用した簡単なプログラムを紹介いたします。

Node.jsに対するRyan Dahl氏の後悔

Ryan Dahl氏は2018年のJSCONF.EUで「10 Things I Regret About Node.js」と題した講演を行いました。

タイトルでは10個となっていますが、なぜか話をしたのは下記の7個だけです。

  1. Not sticking with Promises
  2. Security
  3. Build System
  4. package.json
  5. node_modules
  6. require(“module”) without the extension “.js”
  7. index.js

各項目を確認していきましょう。

Not sticking with Promises

I added promises to Node in June 2009, but foolishly removed them in February 2010. Promises are the necessary abstraction for async/await. It’s possible unified usage of promises in Node would have sped the delivery of the eventual standardization and async/await. Today Node’s many async APIs are aging badly due to this.

async/await を使用するためPromiseは必要であったが、2010年にNodeから削除してしまった。

Security

V8 by itself is a very good security sandbox. Had I put more thought into how that could be maintained for certain applications, Node could have had some nice security guarantees not available in any other language. Example: Your linter shouldn’t get complete access to your computer and network.

V8自体のセキュリティ対策は優れていたがNodeに反映できていない。

ディスクアクセス、ネットワークアクセス等が不要なプログラムであっても権限が与えられた状態で実行されてしまう。

Build System

The continued usage of GYP is the probably largest failure of Node core. Instead of guiding users to write C++ bindings to V8, I should have provided a core foreign function interface(FFI). Many people early on, suggested moving to an FFI (namely Cantrill) and regrettably I ignored them. And I am extremely displeased that libuv adopted autotools.

GYPを使わずFFIを使うべきだった。

FFIを使用して毎回のコンパイルを避けるべきだった。

※こちらはあまり理解できていませんがビルドツールとしてGYPを使用したのを後悔しているようです。

package.json

Allowing package.json gave rise to the concept of a “module” as a directory of files. This is not a strictly necessary abstraction – and one that doesn’t exists on the web. package.json now includes all sorts of unnecessary information. License? Repository? Description? It’s boilerplate notice. If only relative files and URLs were used when importing, the path defines the version. There is not need to list dependencies.

Web常に存在しない概念。

不要な情報(ライセンス、リポジトリ、説明)が含まれており、定型文と化している。

バージョン情報はインポートする際のパスに含められれば依存関係の記載は不要。

node_modules

It massively complicates the module resolution algorithm. vendored-by-default has good intentions, but in practice just using $NODE_PATH wouldn’t have precluded that.

モジュール解決が複雑。

require(“module”) without the extension “.js”

Not how browser javascript works. You cannot omit the “.js” in a script tag src attribute. The module loader has to query the file system at multiple locations trying to guess what the user intended.

requireはjavascriptの仕様ではないのでブラウザで動作しない。

ブラウザでのrequireにあたるscriptタグのsrcでは拡張子の省略はできない。

モジュールのパス解決のために複数の場所を検索する必要がある。

index.js

I thought it was cute, because there was index.html… It needlessly complicated the module loading system. It became especially unnecessary after require supported package.json.

index.htmlがあったので可愛いと思ってindex.jsを作ったが、モジュールの読み込みを複雑にしてしまった。

package.jsonができてから不要となった。

Denoでの解決

上記後悔を踏まえてRyan Dahl氏はDenoでどのような解決を図ったのでしょうか。

Not sticking with Promises

Denoのすべての非同期アクションではpromiseが返却される。

Security

Denoには、ファイル、ネットワーク、および環境へのアクセスに対する明示的なアクセス許可が必要。

実行時に権限オプションを付与することでアクセスが許可される。

下記例では、ネットワークアクセスを許可するために「–allow-net」をオプションで指定している。

deno run --allow-net https://deno.land/std@0.107.0/examples/curl.ts https://example.com

Build System

GYP から GN に変更された。

※こちらに関しては詳細わかりませんでした、ビルドツールの変更は行われたようです。

package.json / node_modules

Denoではパッケージ管理マネージャ(npm)は提供されない。

外部ライブラリを使用する場合はimport時にURLを指定する。

下記例では、バージョンもURLに含めバージョン固定を行っている。

import * as log from "https://deno.land/std@0.107.0/log/mod.ts";

なお、複数のソースコードでのバージョン整合性を取るために、下記のように外部ライブラリを一つのファイルにまとめてexportする方法をDenoでは推奨しています。

インポート集約ファイル

export {
    assert,
    assertEquals,
    assertStrContains,
} from "https://deno.land/std@0.107.0/testing/asserts.ts";

インポート先ファイル

import { assertEquals, runTests, test } from "./deps.ts";

require(“module”) without the extension “.js”

Denoではrequireはサポートされていない。

ES Modules のようにimport を使用してモジュールをロードする。

index.js

Denoではindex.jsによるモジュールのロードはサポートされていない。

その他

TypeScriptのサポート

TypeScriptをNode.jsで使用するには別途ライブラリ追加が必要でしたがDenoではデフォルト状態で使用できるようになりました。

トップレベルでのawait

Node.jsではawaitは関数内でのみ利用可能でしたが、Denoではトップレベルでawaitが利用可能となりました。

Denoでプログラムを動かしてみる

さてここまで、Denoが出来た経緯などご紹介しましたが、ここからは実際にDenoを使用してサンプルプログラムを実行していきます。

環境構築

インストールに関してはこちらに公式ドキュメントがありますので参考にしてください。

私の環境はMacなのでHomebrewでインストールしました。

brew install deno
deno -V
deno 1.14.0

インストールが終わったらサンプルプログラムの実行をしてみましょう。

Deno公式で配布されているプログラムを実行してみます。

~ deno run https://deno.land/std@0.107.0/examples/welcome.ts
Download https://deno.land/std@0.107.0/examples/welcome.ts
Check https://deno.land/std@0.107.0/examples/welcome.ts
Welcome to Deno!

Denoではサーバ上にあるプログラムを直接URL指定で実行することが可能です。

一旦DL&キャッシュされたのちに実行されるため、2回目以降の実行ではDownloadは行われません。

キャッシュを削除して再度DLする際には起動オプションに —reload を付与します。

~ deno run --reload https://deno.land/std@0.107.0/examples/welcome.ts

次にIDEへ拡張機能を追加します。

VSCodeやIntelliJ、Atom、SublimeTextなどがサポートしていますので環境に合わせてご利用ください。

私はVSCodeを使用しているので下記拡張を追加しました。

https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno

プログラム作成と実行

curl.ts

下記はcurlコマンドのようにURLを指定するとhtml内容を取得、表示するプログラムです。

const url = Deno.args[0]
const res = await fetch(url)

const body = new Uint8Array(await res.arrayBuffer())
await Deno.stdout.write(body)

トップレベルでのawaitが利用でき、fetchではPromiseが返却されていることがわかると思います。

では実際に実行してみます。

➜ deno_sample deno run curl.ts https://example.com 
error: Uncaught PermissionDenied: Requires net access to "example.com", run again with the --allow-net flag
const res = await fetch(url)
                          ^
    at deno:core/01_core.js:106:46
    at unwrapOpResult (deno:core/01_core.js:126:13)
    at Object.opSync (deno:core/01_core.js:140:12)
    at opFetch (deno:ext/fetch/26_fetch.js:65:17)
    at mainFetch (deno:ext/fetch/26_fetch.js:220:61)
    at deno:ext/fetch/26_fetch.js:452:11
    at new Promise (<anonymous>)
    at fetch (deno:ext/fetch/26_fetch.js:412:15)
    at file:///Users/xxxxx/Projects/deno_sample/curl.ts:2:19

単純に実行すると「Security」の部分の記載通り、デフォルトではネットワークアクセス権限が付与されていないためfetchで権限エラーが発生します。

allow-net=example.com を起動オプションに付与して実行します。

➜ deno_sample deno run --allow-net=example.com curl.ts https://example.com
<!doctype html>
<html>
<head>
    <title>Example Domain</title>
〜以下略〜

正常にhtml内容の取得、表示ができました。

「—allow-net=example.com」の「example.com」部はアクセス可能なドメインを指定しています。

こちらはカンマ区切りで複数指定することが可能ですが、毎回アクセス先を追加していくのは大変なので「—allow-net」のみとすることでドメインにかかわらずアクセス可能とすることが多いかもしれません。

なお、起動オプションでの権限設定は複数ありますが、良く使うオプションは今回使用した「—allow-net(ネットワークアクセス)」と「—allow-read(ファイル読み込み)」「—allow-write(ファイル書き込み)」あたりでしょうか。

cat.ts

下記はcatコマンドのようにファイル名を指定すると内容を取得、表示するプログラムです。

import { copy } from "https://deno.land/std@0.107.0/io/util.ts"

const filenames = Deno.args
for (const filename of filenames) {
    const file = await Deno.open(filename)
    await copy(file, Deno.stdout)
    file.close()
}

「package.json / node_modules」の部分の記載通り、import from ではライブラリのURLを直接指定しています。

では実際に実行してみます、今回はファイルの読み込みが発生するため「—allow-read」オプションを付与して実行しています。

➜ deno_sample deno run --allow-read cat.ts ./cat_sample.txt
 cat sample string 1
 cat sample string 2
 cat sample string 3

http_server.ts

次にhttpサーバを立ててみます。

下記ではlocalhost:8080で受付を行い、「Hello World」をレスポンスで返しています。

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

const port = 8080;
const listener = Deno.listen({ port });

console.log(`server listening on http://localhost:${port}/`);

await serve(listener, (req) => {
    const responseText = "Hello World";
    return new Response(responseText, { status: 200 });
});

では実行し、ブラウザでアクセスしてみましょう。

➜ deno_sample deno run --allow-net http_server.ts
 Check file:///Users/xxxxx/Projects/deno_sample/http_server.ts
 server listening on http://localhost:8080/

localhost:8080へアクセスするとHello Worldがブラウザに表示されました。

HTTPサーバミドルウェア

最後にNode.jsのexpressのような機能をもったミドルウェアを2件紹介して締めたいと思います。

/endpoint をエンドポイントとしてGET、POST、PUT、DELETEの4メソッドを実装しました。

oak

import { Application, Router } from "https://deno.land/x/oak@v9.0.0/mod.ts";

const app = new Application();
const router = new Router();
const port = 8080;

router.get("/endpoint/:id", (ctx) => {
    const id = ctx.params.id;
    ctx.response.body = `GET [id=${id}]`;
});

router.post("/endpoint", async (ctx) => {
    const body = ctx.request.body();
    const data = await body.value;
    const text = data.text;
    ctx.response.body = `POST [text=${text}]`;
});

router.put("/endpoint/:id", async (ctx) => {
    const id = ctx.params.id;
    const data = await ctx.request.body().value;
    const text = data.text;
    ctx.response.body = `PUT [id=${id} text=${text}]`;
});

router.delete("/endpoint/:id", (ctx) => {
    const id = ctx.params.id;
    ctx.response.body = `DELETE [id=${id}]`;
});

app.use(router.routes());
await app.listen({ port });

パスパラメータの定義はNodeExpressと同じで「:key」で表現し、ハンドラ引数のcontext.paramsから取得可能です。

また、POST、PUTでJSON形式でリクエストを送信した場合のリクエストデータは、body.valueにparseされた状態で格納されます。

NodeExpressを使用していた方はこちらがとっつきやすいかもしれません。

GETリクエスト確認

➜ ~ curl http://localhost:8080/endpoint/10
 GET [id=10]%

POSTリクエスト確認

➜ ~ curl -X POST -H "Content-Type: application/json" -d '{"text":"post text"}' http://localhost:8080/endpoint
 POST [text=post text]%

PUTリクエスト確認

➜ ~ curl -X PUT -H "Content-Type: application/json" -d '{"text":"put text"}' http://localhost:8080/endpoint/10
 PUT [id=10 text=put text]%

DELETEリクエスト確認

➜ ~ curl -X DELETE http://localhost:8080/endpoint/10
 DELETE [id=10]%

pogo

import pogo from "https://deno.land/x/pogo@v0.5.2/main.ts";
import { readAll } from "https://deno.land/std@0.107.0/io/util.ts";

const port = 8080;
const server = pogo.server({ port });
const router = server.router;

router.get("/endpoint/{id}", (req) => {
    const id = req.params.id;
    return `GET [id=${id}]`;
});

router.post("/endpoint", async (req) => {
    const bodyText = new TextDecoder().decode(await readAll(req.body));
    const data = JSON.parse(bodyText);
    const text = data.text;
    return `POST [text=${text}]`;
});

router.put("/endpoint/{id}", async (req) => {
    const id = req.params.id;
    const bodyText = new TextDecoder().decode(await readAll(req.body));
    const data = JSON.parse(bodyText);
    const text = data.text;
    return `PUT [id=${id} text=${text}]`;
});

router.delete("/endpoint/{id}", (req) => {
    const id = req.params.id;
    return `GET [id=${id}]`;
});

server.start();

こちらもExpress風ですが、パスパラメータの定義が「{key}」となっていますので注意が必要です。

また、POST、PUTでJSONが送られてきた場合手動でparseする必要があり、少し手間ですね。

GETリクエスト確認

➜ ~ curl http://localhost:8080/endpoint/10
GET [id=10]%

POSTリクエスト確認

➜ ~ curl -X POST -H "Content-Type: application/json" -d '{"text":"post text"}' http://localhost:8080/endpoint
POST [text=post text]%

PUTリクエスト確認

➜ ~ curl -X PUT -H "Content-Type: application/json" -d '{"text":"put text"}' http://localhost:8080/endpoint/10
PUT [id=10 text=put text]%

DELETEリクエスト確認

➜  ~ curl -X DELETE http://localhost:8080/endpoint/10
GET [id=10]%

まとめ

ここまでDenoの出来た経緯、サンプルプログラムの紹介を紹介いたしました。

今回Denoについて調査、実際にプログラムを実行してみましたが、Node.jsと比べるとpackage.jsonやnpmが無くなったためだいぶ環境構築周りの煩雑さが消えた印象でした。

今まで蓄積されてきたnpmのモジュールが使えない、importのURLバージョン指定がファイル単位に必要などまだ問題は抱えている状態ですが、メリットもたくさんありますので今後Node.jsからDenoへの開発者の移行は進むのではないでしょうか。

Denoの今後に期待ですね。

この記事を書いた人

tn1302 フルスタックエンジニア

新卒でSIerに入社、Java、Oracleで業務システムWeb化の設計開発に従事する。 転職後、Web業務システム、スマホアプリの開発など多岐にわたる設計開発に携わる。 Fixelでは多数のお客様を相手に新規事業のための迅速なプロトタイプ作成と商用化・運用までを支援している一方、自社プロダクトであるUXHubの開発にも関わっている。

Contact

どのようなお悩みでも
まずはお気軽に
ご相談ください

arrow

Careers

柔軟で先進的な思考を持った
デザイナーやエンジニア、
コンサルタントを募集しています

arrow

Copyright© Fixel Inc. All rights reserved.