What is it, naokirin?

Juliaで簡単なWeb APIサーバーを建てる

最近、データ分析した結果をフロントエンドのサーバーに返す、Juliaのバックエンドサーバーを実装していました。そこで、そのコアとなる実装を紹介し、Juliaで簡単なWeb APIサーバーを実行できるようにするためのソースコードを紹介します。

手っ取り早く実行してみたい人は以下のGitHubリポジトリにアクセスしてください。また本記事を読み進める方も、適宜こちらの実装を参照してください。 github.com

今回は、非常にシンプルなJSONによるやり取りをするだけの小さなサーバーを構築することを目的としています。一般的なHTMLのビューを持つようなサーバーや、複雑なWebサーバーの構築を考えている方は、GenieというWebフレームワークを利用するほうが、良いかもしれません。

github.com

実装時のバージョン

  • Julia 1.5.3

設計方針

簡単なWebサーバーですが、MVCアーキテクチャを採用します。ただし、レスポンスのJSONは複雑でないものとして、Viewは省略します。

f:id:naokirin:20210404161342p:plain

  • server: 指定のポートをリッスンしてリクエストを受け付け、データのルーティングに渡して、結果をクライアントに返す
  • routes: 渡されたパスをもとに、Controllerに処理を委譲する
  • Controller: 特定のリクエストに紐づく処理を実行する
  • Model: 各種ビジネスロジックを実行する

今回は、server部分の実装を主に紹介し、それ以外は紹介をしたほうがよい部分に絞って紹介します。

Juliaのプロジェクトを作成する

まずはプロジェクトを作成します。

$ julia
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.5.3 (2020-11-09)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia> # ここで `]` を入力して pkgモードに移行

(@v1.5) pkg> generate HelloWorldServer

これで、 HelloWorldServer というプロジェクトが作成されます。

ディレクトリ構成は最小限ですが以下となります。

.
`-- HelloWorldServer
    |-- Project.toml
    `-- src
        `-- HelloWorldServer.jl

必要なパッケージを導入する

次に必要なJuliaパッケージを導入します。pkgモードで activate . を実行すると、現在のディレクトリのプロジェクトが有効化されます。

(@v1.5) pkg> activate .
 Activating new environment at `/path/to/Project.toml`

この状態で、 add <パッケージ名> として、必要なパッケージをプロジェクトに導入していきます。

(@v1.5) pkg> add HTTP

最低限のWebサーバーとしては HTTP.jl だけでも良いですが、今回はJSONでやり取りできるWeb APIサーバーを目指し、以下の5つのパッケージを導入します。

Glob
HTTP
JSON
PackageCompiler
TOML

HTTPリクエストを受け付ける

それでは、HTTPリクエストを受け付ける部分を実装しましょう。細かい解説はしませんが、コメントを読んでいただければある程度は把握できるかと思います。

using HTTP

# タイムアウト時の例外を定義
struct TimeoutError <: Exception end

function run(host, port)

  # HTTPリクエストを特定のポートでリッスンする
  HTTP.serve(host, port, reuseaddr=true, rate_limit=100000//1) do request::HTTP.Request

    # パスを取得する
    path = request.target
    println("Request $path")
  
    # 送信されたデータの中身を取得する
    # 今回はすべてがPOSTとして扱っているが、GETもある場合は、 request.method で処理を分ける
    payload = Nothing
    try
      payload = JSON.parse(IOBuffer(HTTP.payload(request)))
    catch e
      println("500 Internal Server Error: $e")
      Base.showerror(Base.stdout, e, catch_backtrace())
      return HTTP.Response(500, "Error: $e")
    end
  
    # Channelを用いて、リクエストの処理を並行処理する
    channel = Channel(spawn=true) do ch
      try
        # ルーティングにデータを渡して、結果を受け取る
        # routesはルーティングをする関数(別途定義)
        answer = routes(path, payload)
  
        # ChannelにHTTPレスポンスとして結果を返す
        isopen(ch) && put!(ch, HTTP.Response(answer))
      catch e
        println("500 $e")
        Base.showerror(Base.stdout, e, catch_backtrace())
        isopen(ch) && put!(ch, HTTP.Response(500, "Error: $e"))
      end
    end
  end
  
  println("running...")

  # 処理が終わるまで5秒待機
  timedwait(() -> isready(channel), 5.0)
  println("finished")
  
  # 5秒待っても処理が完了しない場合は、タイムアウト扱いとする
  if !isready(channel)
    isopen(channel) && close(channel)
    println("408 Timeout")
    return HTTP.Response(408, "Error: Request Timeout")
  end

  # Channelで設定されたHTTPレスポンスのデータを取得して返す
  result = take!(channel)
  isopen(channel) && close(channel)
  return result
end

function main()
  run("0.0.0.0", 8000)
end; main()

ここでいくつか利用している機能があるので紹介します。

HTTP.jl は、HTTPのリクエスト、レスポンスをJuliaで実行するためのパッケージです。詳細は以下のドキュメントを参照していただきたいですが、今回は HTTP.serve() で特定のポートでHTTPリクエストをリッスンするようにしています。

juliaweb.github.io

実際にリクエストを受けたあとの処理で重要になるのが、Channelによる並行処理です。ここを並行処理としないと、リクエストに対する処理が終わるまで、それ以降の他のリクエストの処理もストップしてしまいます。Channelで並行処理にすることで処理中に他のリクエストを受け付け、処理を開始させることができます。

Channelでは spawn 引数で実際にメインスレッドではなく別スレッドで実行させるかを選択することができます。今回は true で設定しているため、スレッドを作成して実行されるようになっています。これは以降の処理がスレッドセーフにすべき点に注意が必要です。

docs.julialang.org

ルーティング、Controller、Model

さて、リクエストの受け付けはできるようになったので、ルーティングやController、Modelの実装です。

ここについてはそこまで特殊なことをしていないので、リポジトリの各実装を読んでいただければと思います。

ルーティング https://github.com/naokirin/julia_helloworld_server/blob/main/app/routes.jl

Controller https://github.com/naokirin/julia_helloworld_server/tree/main/app/src/controllers

Model https://github.com/naokirin/julia_helloworld_server/tree/main/app/src/models

ここでは、Controller、Modelの部分をパッケージとするために、 src/HelloWorldServer.jl の実装を紹介します。

module HelloWorldServer

# 全ソースコードのリストを取得する
function allsources(dir)
  contents = String[]
  for (root, dirs, files) in walkdir(dir)
    push!.(Ref(contents), joinpath.(root, filter(x -> splitext(x)[2] == ".jl", files)))
  end
  return contents
end

# モデル、コントローラをHelloWorldServerモジュールにインクルードする
# これにより、各実装でモジュール定義していなくても、HelloWorldServerモジュールで実装されている状態になる
srcdir = @__DIR__
for file in allsources(srcdir * "/models")
  println(file)
  include(file)
end

for file in allsources(srcdir * "/controllers")
  println(file)
  include(file)
end
end

ここまでして、Controller、Modelをパッケージとして定義するのはなぜか気になる方もいるかと思います。

それは、実際のサーバー実装をする際に「テストコードを実装しやすくするため」です。テスト導入は別途記事を書いているのでそちらを参照していただきたいですが、Controller、Modelもベタ書きすると、テストが実行しにくいので、Controller、Modelの部分の実装はパッケージとして定義することをおすすめします。

naokirin.hatenablog.com

実行する前に…PackageCompiler

ここまでで以下のようなコードを用意すると、サーバーを実行することができるようになります。

bootstrap.jl

cd(@__DIR__)
import Pkg
Pkg.pkg"activate ."

ROOT_PATH = pwd()
push!(LOAD_PATH, ROOT_PATH, "src")

include(joinpath("src", "HelloWorldServer.jl"))
include("server.jl")
$ julia  --color=yes --depwarn=no -q -i -- bootstrap.jl s

ただし、このままだと実際のサーバーとして実行する上で少々辛い部分があります。それは起動時の遅さです。起動時に各パッケージを読み込み、precompileを実行するため、起動までにそこそこ時間がかかります。

この起動時間を解消するのに、PackageCompilerでパッケージを先にコンパイルして利用するように変更します。

using PackageCompiler, TOML

# Project.toml から依存するパッケージ一覧を取得する
packages = keys(TOML.parsefile("./Project.toml")["deps"])

# パッケージをコンパイルする
create_sysimage(Symbol.(packages); sysimage_path="sysimage/sysimage.dylib")'

このパッケージのコンパイルは時間がかかります(パッケージ数にも依りますが数分かかります)。

完了すると、 sysimage/sysimage.dylib ができるので、これを利用してサーバーを起動します。利用するには、オプションの --sysimage で指定します。

$ julia --sysimage sysimage/sysimage.dylib --color=yes --depwarn=no -q -i -- bootstrap.jl s

これにより、起動時間が大幅に短縮されます。

まとめ

簡単なWeb APIサーバーをJuliaで実装する場合について紹介しました。

紹介ということで、細かい点はかなり省略しているので、詳細はリポジトリを見てください。

Juliaでデータ分析等を行った結果をAPIとして返せるようになると、Julia自体の活用できる幅も広がるかなと思います。プリコンパイルやPackageCompiler等、実用的にするための工夫も必要ですが、一度手順が明確になれば、そこまで複雑な手順ではないので、もしJuliaにチャレンジする場合はこういった活用にもチャレンジしてみると良いかもしれません。