class: center, middle # http4s(server)入門 2023-10-13 Scalaわいわい勉強会 by: Windymelt --- # Windymelt - [Windymelt](https://www.3qe.us/)です - 全宇宙でこのIDを使っています - GitHub, X/Twitter, etc... - PGP: `F2FC 63C2 42C0 4D9D` - 2016- [株式会社はてな](https://hatena.co.jp/) - はてなブックマークScala化プロジェクトなどに参加 - プライベート - [動画生成ツールZMM](https://www.3qe.us/zmm/doc) - Scalaの記事たくさん[書いてる](https://blog.3qe.us/category/scala) - インターネットが大好き --- # Windymelt
--- class: center, middle # http4sの話をします --- class: center, middle # シンプルで魅力的なhttp4s --- # 注意事項 - http4s v0.23の話をします - v1はまだ開発中なので・・・ - `scala-cli`使ってる? - 手元ですぐサンプルコード実行してほしい - 今すぐダウンロードして!!! - https://scala-cli.virtuslab.org/install/ --- # 巷にHTTPライブラリは沢山ありますが・・・ - ぶっちゃけ何でも良くない? - と言いつつ、程良い規模感のライブラリはあまりない - Play *Framework* - デカい!! - Akka HTTP - AkkaとActorSystemがついてくる・・・ --- # そこでhttp4s - 程々にコンパクト - ちょっとしたJSONを返すサーバからストリーミングまで、そつなくこなす - Scalaの世界観にマッチしている - 関数型パワーを享受 - モジュラリティが高く、合成可能 - e.g. `fooRouting <+> barRouting` - 周辺ライブラリが充実している - Smithy4s -- IDLからインターフェイスを自動生成 - Tapir -- インターフェイス定義ライブラリ - Pac4j -- OAuthなどで認証・認可 - http4sにはクライアント実装もあるので揃えられる - 今日はクライアントの話はしない --- # Simple as Power - RackとかPlackのScala版みたいな存在がhttp4s - 素朴さがある - Playのようなフレームワークではない - テンプレートエンジンを内蔵していない - ルーティング定義ファイルを持たない - バックエンドとなるサーバ実装が分離しているのでプラットフォームに応じて切り替えられる - Ember, Blaze, Netty... - Scala.jsやScala Nativeに対応できる!!! --- # なぜScalaか - 今更言うまでもないですが・・・ - はやい・うまい・型安全 - OOP × FP = ∞ - Javaの資産を活用しつつ - 強力な型システムで静的に保証できる範囲を広げる - 優秀なビルドシステム - Scala処理系のバージョン、ライブラリのバージョンがプロジェクト単位で独立して管理される - 「なんかライブラリのインストールがうまくいかない」なんてことないですよね - Scala CLIと組み合わせるとスクリプト一枚で全ての依存性をインストール可能 --- class: center, middle # http4s入門するの難しい --- # 日本語情報あまりない - Scalaあるある - http4sは開発が活発なので以前書かれた日本語のブログが古びていたりする - 書いてくれ〜!!! --- # マニュアルは割と網羅的だがかなり簡素 - `Content-Type`いじるときどうするんだっけ・・・みたいな時に弱い - Scaladoc読むと抽象的すぎて分からない - `type Http[F[_], G[_]] = Kleisli[F, Request[G], Response[G]]` - ?????? --- # なんかよくわからん・・・となったときに詰まりがち - パッケージ多い - `org.http4s.なんとか` みたいなやつが無限にある - Battery-includedなわけではない - 必要に応じて外部ライブラリに頼るという設計 - JSONとか --- # メンタルモデルを会得するのに時間がかかる - **そこで、今回はそれをガッとやっちゃいましょうという企画です**(本題) --- class: center, middle # よっしゃやっていくぞ --- class: center, middle # まずはメンタルモデルから --- # http4sの構造 (最上部) - HTTPアプリケーション記述のためのDSL - HTTPアプリケーション - リクエストを受け取り、処理してレスポンスを返す一種の関数 - HTTPサーバ実装 - Ember, Blaze, ... - 非同期処理基盤 - デフォだとCats Effect (最下部) --- # 閑話休題: Cats Effectをめちゃくちゃザックリ説明すると - IOという枠組みを用意してくれる基礎的ライブラリ - 非同期処理を型安全にやれて嬉しいぜ - `IO { ... }`って書く単位でグリーンスレッドが動作する - Goroutineみたいな仕組みを提供する - 普通に使うぶんにはhttp4sが勝手にやってくれるのであまり触ることはない - 型定義とかに出てくる - オタク君向け情報 - 初心者向けにめちゃくちゃ端折っています --- # ユーザが触るのは・・・ - HTTPアプリケーション - 必要に応じてMiddleware --- # パッケージで見ると - 直下 `org.http4s._` - 基本的なHTTPのパーツ: `Method`とか`Header`とか - DSL: `org.http4s.dsl.io._` - ルーティング定義 - サーバ実装: `org.http4s.ember.server._` - サーバ実装固有の定義: `EmberServerBuilder`とか - `IO`: `cats.effect._` - IPアドレスのinterpolation: `com.comcast.ip4s._` - `ipv4"0.0.0.0"`といったのを提供する - 探し方: scaladexからscaladocのリンクを開いて検索する --- # ライブラリレベルで見ると - コア機能 - `"org.http4s" %% "http4s-core" % "0.23.23"` - サーバが勝手に連れてくるのでわざわざ書かなくてもよい - サーバ(Ember) - `"org.http4s" %% "http4s-ember-server" % "0.23.23"` - DSL - `"org.http4s" %% "http4s-dsl" % "0.23.23"` --- # 最小限実装を紹介していくコーナー - そろそろScala CLIのインストール終わった? - 適当なディレクトリに`server.scala`を作ろう --- # Optional braces / fewer braces - 紙幅の都合でサンプルコードでは後者の記法を使っています ```scala // explicit val routes = HttpRoutes.of[IO] { case GET -> Root => Ok("") } // fewer val routes = HttpRoutes.of[IO]: case GET -> Root => Ok("") ``` --- ### 最小限のサーバ ```scala //> using scala 3.3.0 //> using dep org.http4s::http4s-ember-server:0.23.23 //> using dep org.http4s::http4s-dsl:0.23.23 import cats.effect.{IO, IOApp, ExitCode} import com.comcast.ip4s._ // for ipv4 and port import org.http4s.HttpApp import org.http4s.ember.server.EmberServerBuilder object Main extends IOApp.Simple: def run: IO[Unit] = EmberServerBuilder .default[IO] .withHost(ipv4"0.0.0.0") .withPort(port"8080") .withHttpApp(HttpApp.notFound) // なにがなんでもNot Foundを返す .build .useForever .as(ExitCode.Success) ``` --- ### **実用できる**最小限のサーバ ```scala // 先程のコードに追加してルーティングを定義する。 import org.http4s.dsl.io._ val routes = HttpRoutes.of[IO]: case GET -> Root / "hello" / name => Ok(s"Hello, $name !") object Main extends IOApp.Simple: def run: IO[Unit] = EmberServerBuilder .default[IO] .withHost(ipv4"0.0.0.0") .withPort(port"8080") * .withHttpApp(routes.orNotFound) .build .useForever .as(ExitCode.Success) ``` --- # ルーティング定義は合成できる ```scala // a.scala object A: val routes = HttpRoutes.of[IO]: case GET -> Root / "a" / args => Ok(s"A received $args") // b.scala object B: val routes = HttpRoutes.of[IO]: case GET -> Root / "b" / args => Ok(s"B received $args") // main.scala import cats.syntax.all._ val routes = A.routes <+> B.routes // attempt A.routes, then B.routes ``` --- # HTML返す - 何もしないと`text/plain`で返るのでヘッダを当ててやる - `Ok()`などのレスポンスの第2以降引数にはヘッダを置けるんじゃ - dslモジュールの`OkOps`に定義されている - 余談: `http4s-twirl`というtwirl(テンプレートエンジン)と組み合わせるバインディングがある。これを使うと勝手にヘッダーつけてくれる ```scala // ... import org.http4s.dsl.io._ import org.http4s.headers.`Content-Type` import org.http4s.MediaType Ok("ok", `Content-Type`(MediaType.text.html)) ``` --- # 静的ファイル配信したい - `org.http4s.StaticFile`使いましょう ```scala // 実例: resourceに追加されているファイルを配信する object OgimagekunRoutes: def static(file: String, request: Request[IO]) = StaticFile.fromResource("/" + file, Some(request)).getOrElseF(NotFound()) val ogpRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> Root / "ogp.svg" => static("ogp.svg", req) } ``` --- # JSON送受信したい - Circeバインディング(`http4s-circe`)がある ```scala // ... //> using dep org.http4s::http4s-circe:0.23.23 //> using dep "io.circe::circe-generic:0.14.5" //> using dep "io.circe::circe-literal:0.14.5" // .asJsonを使えるようにする import io.circe.syntax._ // JSONオブジェクトをレスポンスとして返せるようにする import org.http4s.circe._ // 自動的にJSONへの変換を導出する import io.circe.generic.auto._ case class Song(title: String, releasedYear: Int, artist: String) // ... Ok(Song("ずんだシェイキング", 2023, "なみぐる").asJson) ``` --- # JSONを受け取る ```scala import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe._ import io.circe.generic.auto._ val routes = HttpRoutes.of[IO]: case req @ POST -> Root => for { s <- req.as[Song] // => IO[Song] r <- Ok(s.title) } yield r ``` --- # 非同期処理を呼びたい - 実はIOを渡すとhttp4s側でよしなにハンドリングしてくれる - `IO.fromFuture`を呼ぶと`Future`を`IO`に変換できる - `IO[Future[A]]`を`IO[A]`にしてくれる ```scala val heavy: IO[Int] = IO(42) val routes = HttpRoutes.of[IO]: // OkはIO[Response[IO]]を返すのでflatMapすればよい case GET -> Root => heavy.flatMap(n => Ok(n.toString)) ``` --- # ストリーミングしたい - 実はhttp4sの中身にはfs2が使われている - fs2: ストリーミングライブラリ - fs2のストリームを渡すと勝手に流してくれる ```scala import scala.concurrent.duration._ import fs2.Stream val clockEverySeconds: Stream[IO, String] = Stream .awakeEvery[IO](1.second) .evalMap(_ => IO(java.time.OffsetDateTime.now().toString())) .intersperse("\n") val routes = HttpRoutes.of[IO] { case GET -> Root => // ずっと1秒ごとに時刻が流れていく Ok(clockEverySeconds) } ``` --- class: center, middle # おすすめテクの紹介 --- # アクセスロギング `org.http4s.server.middleware.Logger`を標準で使える。 ```scala // こんな感じで覆ったものを元のルーティングのかわりに使う finalHttpApp = Logger.httpApp(true, true)(routes) ``` --- # サーバ再起動 sbtプロジェクトで`sbt-revolver`を使っておくことで、ソースコードの変化に追従してサーバを再起動できる。 ```scala // plugins.sbt addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") ``` ```sh # shell % sbt > ~reStart ``` --- # シャットダウンタイムアウト ブラウザなどからコネクション張りっぱなしになって再起動してくれないことがある。タイムアウトを設定するとすぐ落ちてくれる。 ```scala def run(args: List[String]): IO[ExitCode] = EmberServerBuilder .default[IO] .withHost(ipv4"0.0.0.0") .withPort(port"8080") .withHttpApp(helloWorldService) // デフォルトだと30秒待ってしまうので速攻で終了させる。 // 開発環境では即座に、本番環境では長めに待つといった構成をすると丁寧 * .withShutdownTimeout(Duration(1, "second")) .build .use(_ => IO.never) .as(ExitCode.Success) ``` --- # いかがでしたか? - http4sよくやる技100連発みたいになってしまったが・・・ - 何か作れそうな気がしてきましたか!?