Help us understand the problem. What is going on with this article?

dart2nativeで簡易的なコマンドラインツールを作る

まえおき

Dart Advent Calendar 2019が思いのほか過疎っているので、2日目もなんとなく書いてみます。

この秋にリリースされたDart 2.6でよく取り上げられているのが dart2native というネイティブ実行ファイルをシングルバイナリで出力する機能ではないでしょうか。
Dartの公式Mediumでも言及されています。 https://medium.com/dartlang/dart2native-a76c815e6baf

「コマンドラインツール」, 「シングルバイナリ」 といえばGoが有名ですが、今秋からDartもその仲間入りしました。
(Kotlin/Native...? はよく知らないので誰か教えて下さい :sweat_smile:

dart2nativeは結構なんでもシングルバイナリにできる

Hello Worldできるプログラムがシングルバイナリになって嬉しい人は多分いないでしょう。
コマンドラインツールとして使うのであれば、

  • コマンドライン引数を解析する
  • 標準入力を受け取る
  • JSONをパースする
  • HTTPクライアント
  • HTTPサーバー

あたりはそろっていないと話になりません。

dart2nativeはリフレクションを使うようなライブラリ等、一部を除き、たいてい動かせる(らしい)ので、

  • コマンドライン引数の解析→ args
  • 標準入力を受け取る → dart:io, dart:convert
  • JSONをパースする → dart:io, dart:convert
  • HTTPクライアント → http
  • HTTPサーバー → dart:io

などなど、適当に標準ライブラリを使ってコマンドラインツールは作れそうです。

例: リクエストの中身をPrintし、そのままオウム返しするコマンドラインHTTPサーバー

Webhookの試験をするときとかに使いたいやつです。

./echo_http_server -b 0.0.0.0 -p 3000

Listening on 0.0.0.0:3000...

のように起動して

$ echo '{"hoge":"fuga"}' | http -v POST http://localhost:3000/
POST / HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 16
Content-Type: application/json
Host: localhost:3000
User-Agent: HTTPie/1.0.2

{
    "hoge": "fuga"
}

HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
transfer-encoding: chunked
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block

{
    "hoge": "fuga"
}

HTTPリクエストを受けてオウム返しする簡易的なサーバーを作りたいとします。

雑にかくと、

main.dart
import 'dart:convert';
import 'dart:io';

import 'package:args/args.dart';

class MyArgs {
  final String address;
  final int port;

  MyArgs._(this.address, this.port);

  factory MyArgs.parse(List<String> args) {
    ArgResults parsedArgs = (ArgParser()
          ..addOption("bind", abbr: "b", defaultsTo: "127.0.0.1")
          ..addOption("port", abbr: "p", defaultsTo: "3000"))
        .parse(args);
    return MyArgs._(parsedArgs["bind"], int.parse(parsedArgs["port"]));
  }
}

main(List<String> args) async {
  final myArgs = MyArgs.parse(args);
  final server = await HttpServer.bind(myArgs.address, myArgs.port);

  // print("OS: ${Platform.operatingSystem}");
  // print("OSVersion: ${Platform.operatingSystemVersion}");
  print("Listening on ${myArgs.address}:${myArgs.port}");

  await for (HttpRequest request in server) {
    // POST /webhook のようなURL・メソッド表示
    print("${request.method} ${request.uri}");

    // ヘッダーを順に表示
    request.headers.forEach((String key, List<String> values) {
      values.forEach((String value) {
        print("$key: $value");
      });
    });

    // リクエストボディを表示
    String body = await utf8.decodeStream(request);
    print("\n$body");

    // HTTP 200 OKでリクエストボディの内容をそのまま返す
    await (request.response
          ..statusCode = 200
          ..write(body))
        .close();
  }
}

のようにDartコードを書き、

$ dart2native main.dart -o out/echo_http_server
$ cd out/
$ ./echo_http_server -p 3000

のようにdart2nativeを実行すれば、期待通りのHTTPサーバーが出来上がっているはずです。

dart2nativeの便利なところ、いまいちなところ

class, try/catch がふつうに使える :thumbsup:

Goだと結構頑張らないといけないところが、Dartだとサラッと(Javaのように)無理なく書けます。

数年後も使い続けるようなツールや、並列処理をフル活用したいツールであれば、Goで書くのがよいでしょう。また、Goのほうが快適に書ける人はべつに無理にDartを使う必要はないと思います。
ただ、使い捨てで1〜2週間くらいの間だけなんとなく動いてくれたらいいツールであれば、Dartでさらっと書くという選択肢もあるよ!ということです。

async/await がシンプルに使える :thumbsup:

JavaScriptユーザにはおなじみのasync/await は当然Dartでも使えますし、dart2nativeでも問題なく動作します。

コマンドライン引数の解析結果に型情報がない :rolling_eyes:

args パッケージはPythonのargparse くらいの解析能力はありますが、解析結果が型情報をもっていない(dynamic)のです。
ライブラリの実装的にそうせざるを得ないのはわかりつつも、ソースコードに if (parsedArgs["need-notification"]) { のような型情報がわからないものは散らかしたくないものです。

先のHTTP echoサーバーの例では、

class MyArgs {
  final String address;
  final int port;

  MyArgs._(this.address, this.port);

  factory MyArgs.parse(List<String> args) {
    ArgResults parsedArgs = (ArgParser()
          ..addOption("bind", abbr: "b", defaultsTo: "127.0.0.1")
          ..addOption("port", abbr: "p", defaultsTo: "3000"))
        .parse(args);
    return MyArgs._(parsedArgs["bind"], int.parse(parsedArgs["port"]));
  }
}

このようにコマンドライン引数の解析結果を型情報のある変数に入れ込むだけのデータクラスを用意していました。
おそらく大抵のケースではこのようなデータクラスを用意するのが吉なのではないかなー?と思います。
(もっといい方法があれば教えて下さい)

クロスコンパイルができない :scream:

2019秋時点で、唯一にして最大の欠点を上げるとすればコレかなと思います。

  • Macで dart2nativeして得られるのはMac用のバイナリだけ、
  • Linuxで dart2nativeして得られるのはLinux用のバイナリだけ

です。(see: https://github.com/dart-lang/sdk/issues/28617 )
MacやWindowsでもDockerを使えば簡単にLinux向けのバイナリは作れます :thumbsup:

では、全OS向けにバイナリビルドをしたい場合にどうすればいいか?というと、Dart 2.6のアナウンスでは「CIを利用すればいい」など書いてありました。

dart2native.yml
name: dart2native

on: [push]

jobs:
  build:
    runs-on: windows-latest

    container:
      image:  google/dart:latest

    steps:
    - uses: actions/checkout@v1
    - name: Install dependencies
      run: pub get
    - name: dart2native
      run: dart2native main.dart -v -o out/echo_${{ runner.os }}

GitHub Actionsでこんな感じでやればいいのでしょうか? いいえダメです...

image.png

おそらくこの記事に書かれているように、自前でアクションを定義すればできるでしょう。 うーん...面倒 :sweat_smile:

現時点では、Windows/Mac/Linux全対応やarm向けのバイナリを作りたいケースでは、DartではなくGoを利用するのが現実的な選択肢ではないでしょうか。

まとめ

dart2nativeの登場で、使い捨て/自分専用なコマンドラインツールはDartを使って気軽に書くことができるようになりました。Goに加えて新たな選択肢が登場し、心強いです。

aws-sdk-dartなど便利なライブラリはまだDartにはありませんが、今後もし各種SDKのDart版が登場したら、コマンドラインツールはさらに作りやすくなると思います。
自分用コマンドラインツールを持っている方は、ぜひ一度Dartで何かを作ってみてはどうでしょうか。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away