web開発をメインにしているシステム開発部のCです。
dartといえば、「flutter」アプリですね!

他にもLinuxベースではない、iot向けDart製の組み込みOS「Fuchsia」もありますね!
過去に同OSチームが5年後ぐらいに今のAndroidOSを「Fuchsia」に書き換えたいとも言っていて、
いろんな意味で興味深い言語ではありますね!

DartでアプリやOSも作れるなら、dart製のwebフレームワークもあるのではとふと思いつき、
興味本意で探してみたら案の定ありましたので試してみました!

shelf について

フレームワークの名前は「shelf」です。
これだけでサーバー実装が、node.jsの「express.js」と似たように比較的かんたんに作れます。
「shelf_router」というライブラリも一緒に使うとルーティング周りをきれいに書けるので使用しています。
同じフレームワークでWebsocketもかけるので、それも興味深かったです。
またdartで、GraphQLもかけるのを知ったので今度試して見たいと思います。
今回は、上記のwebフレームワークを使い、簡単なREST APIを作りました。
DBは採用せず、jsonファイルを読み込んだり、書き込んだりしてDBもどきとして作りました。
また、アプリ自体は、仮想環境上で動かしたいのでdockerを用意しました。

dart製 webフレームワーク shelf
dart製 ルート定義用のライブラリ shelf_router

以下、dockerのファイルを格納しているフォルダです。

下記の①、②を使って、実際に開発環境を構築します。

$ tree dart-shelf-root/

dart-shelf-root/
├── dart_rest_api/ #サーバーのプロジェクトフォルダ(下記で説明致します)
├── docker-compose.yml #①
└── web
   └── Dockerfile #②

以下、サーバーのプロジェクトのフォルダ構成です。

$ tree dart_rest_api/

dart_rest_api/
├── README.md
├── bin
│   └── server.dart #サーバーのメインスクリプトファイルです。
├── lib
│   ├── libraries.dart #サーバーで必要するモジュール群をまとめて読み込むのに使用しています。
│   │
│   ├── config #サーバーの設定関連のモジュールです。
│   │   └── api_config.dart
│   │
│   └── routers #ルーティング定義関連のモジュールです。
│       ├── base_router.dart
│       ├── common_api.dart
│       └── user_api.dart
│
├── db #DB代わりにしているjsonファイルです。(DBもどき)
│   └── users.json
│
└── pubspec.yaml #ここでdart製のライブラリを追加します。

dockerの準備

docker入手先(公式)

dockerが手元にある前提で今回はご説明させて頂きます。
予め、DOCKERはご用意下さい。

*docker入手先

入手方法や、設定方法は省かせていただきますが、上記からダウンロードは可能です。
少し検索すればたくさん情報は出ます。簡単なのでぜひ予め各環境に合わせてご用意下さい!

dockerfile の準備

以下、dockerfileで環境構築が出来ます。googleが公式のイメージ「google/dart:2.14.2」を用意しているので、そちらをベースに構築しました。

google公式 Dartのイメージ

以下が、Dockerfileです。webのフォルダに配置して下さい。

FROM google/dart:2.14.2

RUN apt -y update && apt -y upgrade
RUN apt -y install wget vim jq iproute2

ENV APP_ROOT /app
ADD ./dart_rest_api $APP_ROOT

WORKDIR $APP_ROOT
RUN pub get
RUN pub get --offline

CMD []

EXPOSE 8000

docker-compose.yaml の準備

以下のようにコンテナを用意します。
services の直ぐ下にあるwebというスコープが、今回のサーバーのコンテナとなります。
必要な環境変数は、「common-variables-shelf」で用意して、webのコンテナで読み込んでいます。
また、コンテナの起動と同時に実行してほしいコマンドを記述しております。
ここでは、command という箇所に、起動と同時にサーバーを起動して欲しいコマンドを指定しております。
volumesでは、手元のフォルダとコンテナ側で同期をとって欲しいものを設定しております。

version: '3.8'

x-common-variables: &common-variables-shelf
  SERVER_PORT: 8080
  SERVER_HOST: '0.0.0.0'

services:
  web:
    build:
      context: .
      dockerfile: web/Dockerfile
    restart: always
    command: ["/usr/lib/dart/bin/dart", "run", "/app/bin/server.dart"]
    ports:
      - "8000:8080"
    environment: *common-variables-shelf
    privileged: true
    stdin_open: true
    tty: true
    volumes:
      - ./dart_rest_api:/app
      - ./exports:/tmp/exports

ライブラリの準備

pubspec.yaml の準備

アプリで必要となるライブラリを「pubspec.yaml」で登録します。
今回は、以下のライブラリを使用します。

// /dart_rest_api/pubspec.yaml

name: dart_rest_api
description: dart sample rest api
version: 1.0.0

environment:
  sdk: '>=2.8.1 <3.0.0'

dependencies:
  shelf: ^1.1.4
  shelf_router: ^1.1.1

dev_dependencies:
  build_runner: ^1.10.0
  build_web_compilers: ^2.11.0
  pedantic: ^1.9.0

サーバースクリプトの準備

server.dart の準備

下記のメインのサーバースクリプトを用意します。
必要なモジュールはimportで読み込んで、事前に登録してから、await app.serve(XXXX..) でサーバーを起動するという流れな作りになっております。

// /dart_rest_api/bin/server.dart

import 'dart:io';

import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';

import 'package:dart_rest_api/libraries.dart';

void main(List<String> arguments) async {
  final app = Router();
  final appConfig = ApiConfig();

  print('server is running, access with http://localhost:8000');

  app.mount('/', CommonApi().root);

  app.mount('/users/', UserApi().router);

  app.mount('/', CommonApi().any);

  await io.serve(app, appConfig.getHost(), appConfig.getPort());
}

サーバー用の各モジュールの説明 (設定情報とルーティング定義)

サーバーで必要となる設定情報やルート定義のモジュールを以下のように用意しました。

├── lib
│   ├── libraries.dart #①
│   ├── config
│   │   └── api_config.dart #②
│   └── routers
│       ├── base_router.dart #③-1
│       ├── common_api.dart #③-2
│       └── user_api.dart #③-3

上記、各ファイルの概要

① ②と③の用にサーバーが必要とするモジュールを用意して、ここでまとめて読み込むためのファイルです。
② モジュールその1で、サーバー使いまわしたい設定情報を定義します。
③1~3 モジュールその2で、ルーティングの定義をモジュールとしてここに配置します。

以下は、上記の#①の説明です。

これから紹介するモジュールらをまとめて読み込み、exportで公開します。
こうすることにより、上記で説明した、「server.dart」で、必要なモジュールを一行で読み込むことが可能となります。

* server.dart で読み込んでいる例

import 'package:dart_rest_api/libraries.dart';

アプリが大きくなっていくにつれてモジュールも多くなるので、このように管理がし易いようにしました。

// /dart_rest_api/lib/libraries.dart

export 'routers/common_api.dart';
export 'routers/user_api.dart';
export 'config/api_config.dart';

以下は、上記の#②の説明です。

サーバーの基本的な設定情報をモジュールとして用意しました。
環境変数の値はdockerから参照しているものを読み込みます。

// /dart_rest_api/lib/config/api_config.dart

import 'dart:io' show Platform;

class ApiConfig {
  Map<String, String> _envVars = null;
  String _port = null;
  String _host = null;

  ApiConfig() {
    this._envVars = Platform.environment;
    this._port = this._envVars['SERVER_PORT'] ?? '8000';
    this._host = this._envVars['SERVER_HOST'] ?? '127.0.0.1';
  }

  int getPort() {
    return int.parse(this._port);
  }

  String getHost() {
    return this._host;
  }
}

以下は、上記の#③-1の説明です。

ルーティングの定義周りで使い回す共通処理を置くためだけに用意したクラスです。
親クラスとして用意し、下で説明する子クラス(実際のルート定義)で継承します。
継承先ではヘッダーの情報の取得に利用しています。

// /dart_rest_api/lib/routers/base_router.dart

class BaseRouter {
  getHeaders() {
    final headers = {'content-type': 'application/json'};
    return headers;
  }
}

以下は、上記の#③-2の説明です。

もう少し下の方で改めて紹介しますが、今回のメインのハンドラーであるユーザーの情報に対して操作を行うためのエンドポイントの集まりを「user_router.dart」 というファイルに用意しました。
そしてこの「CommonApi」クラスは、「/(ルートパス)」とユーザーのエンドポイント以外(期待してないパス)でURLアクセスしたときのために、対処するルート定義です。

// /dart_rest_api/lib/routers/common_api.dart

import 'dart:io';
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

import 'base_router.dart';

class CommonApi extends BaseRouter {
  Router get root {
    final routerRoot = Router();
    // 「/」でアクセスしたときの対応
    routerRoot.get('/', (Request req) {
      return Response.ok(
        json.encode({"data":"this is root path."}),
        headers: super.getHeaders()
      );
    });

    return routerRoot;
  }

  Router get any {
    final routerAny = Router();
    // 今回サーバーが期待するURLパス以外でアクセスして来たときの対応
    routerAny.all('/<*>', (Request request) {
      return Response.ok(
        json.encode({"data":"not supported route path."}),
        headers: super.getHeaders()
      );
    });

    return routerAny;
  }
}

以下は、上記の#③-3の説明です。

以下は、上記でもすでに少し説明した、ユーザーのルート定義です。
DBもどきのjsonファイルに対して、CRUD操作が行えるようにしました。

// /dart_rest_api/lib/routers/user_api.dart

import 'dart:io';
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

import 'base_router.dart';

class UserApi extends BaseRouter {
  // 静的ファイルの読み込み(今回のDBもどき)
  Map<String, dynamic> data = json.decode(File('db/users.json').readAsStringSync());

  Router get router {
    final router = Router();

    // GET /users/
    router.get('/', (Request req) {
      return Response.ok(json.encode(data), headers: super.getHeaders());
    });

    // GET /users/{id}/
    router.get('/<id|[0-9]+>', (Request req, String id) {
      final parsedId = int.tryParse(id);
      final newData = data["data"]["users"].firstWhere(
        (user) => user["id"] == parsedId,
        orElse: () => {"data": "not found"}
      );
      Map<String, dynamic> response = new Map.from({"data": {"users": newData}});

      return Response.ok(json.encode(response), headers: super.getHeaders());
    });

    // PUT /users/{id}/
    router.put('/<id|[0-9]+>', (Request req, String id) async {
      final payload = await req.readAsString();
      final parsedId = int.tryParse(id);
      final foundUser = data["data"]["users"].firstWhere(
        (user) => user["id"] == parsedId,
        orElse: () => null
      );

      if (foundUser == null) {
        return Response.notFound(
          json.encode({"data": "not found."}),
          headers: super.getHeaders()
        );
      }

      var newData = data["data"]["users"].where((user) => user["id"] != parsedId).toList();
      newData.add(json.decode(payload));
      Map<String, dynamic> response = new Map.from({"data": {"users": newData}});

      return Response.ok(
        json.encode(response),
        headers: super.getHeaders()
      );
    });

    // POST /users/{id}/
    router.post('/', (Request req) async {
      final payload = await req.readAsString();
      data["data"]["users"].add(json.decode(payload));
      Map<String, dynamic> response = new Map.from(data);

      await File('db/users.json').writeAsStringSync(json.encode(response));
      return Response.ok(json.encode(response), headers: super.getHeaders());
    });

    // DELETE /users/{id}/
    router.delete('/<id|[0-9]+>', (Request req, String id) async {
      final parsedId = int.tryParse(id);
      data["data"]["users"].removeWhere((user) => user["id"] == parsedId);
      Map<String, dynamic> response = new Map.from(data);
      await File('db/users.json').writeAsStringSync(json.encode(response));
      return Response.ok(json.encode({"data": "Deleted."}), headers: super.getHeaders());
    });

    return router;
  }
}

DBもどき

以下は、今回用意したDB代わりに使用したjsonファイルの例です。
data.users[x] に、userの情報が読み込み、追加、上書き、削除されています。
以下の状態では、userのデータが2件すでにある状態の例です。

// /dart_rest_api/db/usrs.json

{"data":{"users":[{"id":1,"name":"tanaka"},{"id":2,"name":"suzuki"}]}}

開発環境を動かす

コンテナを起動する

上記のdocker関連のファイルとサーバーのスクリプトの用意が出来たら、
下記のコマンドでコンテナを起動して下さい。

1. イメージのビルド

cd dart-shelf-root
docker-compose build #時間が多少かかるかもしれません。

2.コンテナ起動

cd dart-shelf-root
docker-compose up #これで起動が開始します。

上ではログを進捗や状況を確認頂けるためにあえてわざとバックグランドでの起動はしていません。
なので、現在のターミナルのウィンドウの操作がdockerに取られるので、続くコマンドの実行は、もう一つウィンドウを開いて続けて下さい。

3.コンテナに入れるかを試す

docker-compose exec web bash

最初のターミナルでログにエラーがとくに無く、またコンテナにも入れるようでしたら、とくに問題はありません。
サーバーは起動していて、あとはHTTPリクエストを投げることを試して下さい。
また、手元のサーバーのスクリプトとコンテナのサーバーのスクリプトは同期する設定をしているので、ご自由に修正等していただければその場でコンテナ側にもその修正が反映されます。
一度入ったコンテナから抜けたいときは、exit コマンドで抜けて出せます。

コンテナでなにか問題が起きたとき

問題が出た場合は、吐き出されたログにその理由が出ることが多いのでヒントになります。
ご参考程度に自分のパソコンのバージョンを共有します。

MAC BOOK PRO
$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.15.6
BuildVersion:	19G2021

WINDOWS、MACのM1は持ち合わせてないため、また時間の都合のためもあり動作確認はとっていません。その点は予めご了承下さい。

APIサンプル

ここでは、起動したサーバーのコンテナに対して、実際にAPIリクエストしたときの結果を参考として記載致します。

*備考

jq というコマンドを使用して、APIから返却されたjsonのレスポンスを整形します。

jqコマンドの 公式のサイト

ルートパスの取得

curl -X GET http://localhost:8000/ | jq .

レスポンス
{
  "data": "this is root path."
}

APIがサポートするエンドポイント以外にアクセスしたときのレスポンス例

curl -X GET http://localhost:8000/hogehoge | jq .

レスポンス
{
  "data": "this is root path."
}

ユーザー一覧取得

curl -X GET http://localhost:8000/users/ | jq .

レスポンス
{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "tanaka"
      },
      {
        "id": 2,
        "name": "suzuki"
      }
    ]
  }
}

ユーザー単一の取得

curl -X GET http://localhost:8000/users/1 | jq .

レスポンス
{
  "data": {
    "users": {
      "id": 1,
      "name": "tanaka"
    }
  }
}

ユーザー新規作成

curl -X POST -d '{"id": 3,"name":"honda"}' http://localhost:8000/users/ | jq .

レスポンス
{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "tanaka"
      },
      {
        "id": 2,
        "name": "suzuki"
      },
      {
        "id": 3,
        "name": "honda"
      }
    ]
  }
}

ユーザー更新

curl -X PUT -d '{"id": 3,"name":"nakamura"}' http://localhost:8000/users/3 | jq .

レスポンス
{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "tanaka"
      },
      {
        "id": 2,
        "name": "suzuki"
      },
      {
        "id": 3,
        "name": "nakamura"
      }
    ]
  }
}

ユーザー削除

curl -X DELETE http://localhost:8000/users/3 | jq .

レスポンス
{
  "data": "Deleted."
}

まとめ

以上で、dart製のwebフレームワーク「Shelf」を使った簡単なREST APIの構築をご紹介させて頂きました。どうでしたでしょうか。とても簡単に作れて便利ですよね。
個人、社内用の簡単なwebアプリや、モック(実験的)サーバーとしても使えそうですね。
最後までお読みいただきありがとうございました!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる