これは新歓ブログリレー2024 5日目の記事です。少し遅れてしまいました...
やったこと
「知らない言語でtraQ BOT作るの楽しそうだな」と思ったので急に作りました。
出来上がったものがこちら
原神に出てくるタルタリヤというキャラクターの名台詞、「俺が払うよ」を発するだけのBOTです。
本当にこれだけです。
書くこと
この記事では、いきなりScalaを使ってやったこと、感じたことをつらつらと書いていきます。なお、私は(自称)Rustaceanなので使用する語彙は大体Rustの文脈でよく使われるものです。
環境構築
Nix Flakeのセットアップ
まずはやはりこれですね。この時点でformatterやLSPなども調査をして、以下の構成を組みました。
- OpenJDK
jdk21
- Scala 3
scala_3
(これ後で変えます) - sbt
sbt
- scalaのパッケージマネージャー
- metals
metals
- Language Server
該当commit: ec19b90
あと、overlayを設定したのに効いていないことに後で気づいて直しました。958ec62
れっつえすびーてぃー
雑にsbt init
して一番基礎的に見えるテンプレを選びました。
んですが、最初からテストが用意されてるんですね。あと、ディレクトリが深い。src/main/scala/pkg-name/Main.scala
ってなんだよ!!!!
該当commit: 9ac7955
metals導入
rustだったらrust-toolchain.toml
にrust-anaylzerの存在を記述して終わりだったんですが、metalsの場合はそう簡単な問題ではありませんでした。
というのも、build backendとしてbloopやsbtなどが選べるんですね。デフォルトはbloopでしたが余計なdepsはつけたくないのでsbtにしようと試みました。が、sbtに切り替えてみてもprojects/metals.sbt
(metalsが生成するsbtの設定ファイル)にbloopの依存関係が記述されるので、諦めてbloopを採用しました。
該当commit: b84fca8
ところでこのmetalsの出力がとんでもないんですよね。↓はlsd --tree
の出力から一部抜き取ったものです。
BOT_Tartaglia
├── .bloop
├── project
│ ├── .bloop
│ ├── project
│ │ ├── .bloop
│ │ ├── project
│ │ │ ├── project
│ │ │ │ └── target
│ │ │ │ └── config-classes
│ │ │ └── target
│ │ │ ├── config-classes
│ │ │ ├── scala-2.12
│ │ │ └── streams
│ │ └── target
│ │ ├── config-classes
│ │ ├── scala-2.12
│ │ └── streams
│ └── target
│ ├── config-classes
│ ├── scala-2.12
│ └── streams
└── target
├── global-logging
├── scala-2.12
├── streams
└── task-temp-directory
metalsのドキュメント通りに設定したはずなんですが、何か間違えたかな...
formatterとlinter
これなしでは始まりませんね。rustfmtとclippy, ではなくscalafmtとscalafixを導入しました。設定ファイルはそれぞれ.scalafmt.conf
, .scalafix.conf
を使用し、HOCONというフォーマットで記述します。特殊文法だあ...
scalafmtの導入commit: 0cd9023
scalafixの導入commit: b48e6dd
scalafmtの導入で知ったんですが、Scalaはバージョンごとにdialectと呼ばれるほどの差異があるっぽいですね。
dialectと表現してるのはscalafmtだけかも?まあということで、Scalaのバージョン問題が発生
ところで
sbtはrustup + cargo? と思った話です。
sbt init
で生成されたものの中で、build.sbt
に以下のような記述があります。
ThisBuild / scalaVersion := "2.13.12"
つまり、sbtはscalaのバージョンも管理している...? sbtが提供するのはプロジェクトでの依存ライブラリ管理とそれらをまとめてビルドする方法のみだ、という思い込みがあったのでこれは予想外でした。cargoだけじゃなくてrustupもこなせるのね、という印象です。
Scalaのバージョンどうする
Configuration · Scalafmt #Scala Dialects より
Available dialects are:
scala211
scala212
scala212source3
scala213
scala213source3
scala3
sbt0137
sbt1
さて、どれにしましょうか。とりあえずsbt
系はよくわからんので除外して、scala211
, scala212
, scala213
, scala3
のいずれかで考えます。とりあえずGitHubリポジトリを見にいきます。
- scala/scala: Scala 2 compiler and standard library. Bugs at https://github.com/scala/bug; Scala 3 at https://github.com/scala/scala3
- scala/scala3: The Scala 3 compiler, also known as Dotty.
scala3 a.k.a. dotty!? わけわからん... というわけでとりあえず除外。
Nixを使用している関係上、nixpkgsでscala 2.xの中でもxを陽に指定できるものを選びたいですね。ということでNixOS Searchを覗いてみます。
dialectとNixパッケージ名の対応をまとめると↓です。
dialect | パッケージ名 |
---|---|
scala211 |
scala_2_11 |
scala212 |
scala_2_12 |
scala213 |
scala |
ということで採用: scala212
環境構築終わり
ようやくScalaを書けます。17時に始めてここまでで大体23時です。長かった...
何作るんだっけ
Scalaの環境構築が予想外に重たかったので、ここでもう一度作るものを確認しておきます。
HTTPモードのtraQ BOTサーバーを作ります。詳細な説明はBOT Consoleにあります。
↑などの内容を要約するとこうです。
- traQでBOTを作成すると以下4つのトークンが与えられる
- BOT ID: BOTのみに与えられるID
- BOT User ID: BOTのtraQユーザーとしてのID
- Verification Token: traQからのイベント配信を認証する際に使用するトークン
- Access Token: BOTからtraQにリクエストを投げる際に使用するトークン
- BOTをアクティベーションすると、traQからイベント配信が送られてくるようになる
- HTTP POSTリクエストで送られる
- このイベント配信はヘッダー
X-TRAQ-BOT-TOKEN
の値をVerification Tokenと照らし合わせて認証する
- 配信されたイベントの種類はヘッダー
X-TRAQ-BOT-EVENT
の値を見る - このヘッダー値に応じてリクエストボディのJSONの構造が変わる
- イベント配信に対して、必要に応じてtraQへのリクエストなどを行う
- このリクエストは単にtraQバックエンドサーバーへのHTTPリクエスト
- 詳細はtraQ/docs/v3-api.yaml at 6c33e40 · traPtitech/traQ
- HTTPリクエストではAccess Tokenを用いたBearer認証を行う
つまり、BOT_Tartagliaには以下のような実装が必要です。
- HTTPサーバー
- POSTリクエストを受け取ってそのヘッダーの値を読み、値次第で処理を分ける。
- 処理次第では、リクエストボディをJSONとして扱う
- HTTP(S)クライアント
- Bearer認証
- JSONのリクエストボディを送る
作るべきものがわかったので、ようやく本格的に書き始めることができます。
Let's write Scala
play framework vs ...
さてまずはお決まりのライブラリ選定ですね。今回作るのはフロントエンドもDBもないHTTPサーバーなので、大それた機能は必要ありません。といったことを考えながらplay-samplesを覗いてみましたが、どうにも高機能すぎる気がします。HTTPクライアントを別で用意する必要がありそうなのも微妙なところです。
...といったことを考えながらGoogle検索を繰り返していたら、http4sの紹介記事を見つけました。
http4s は typelevel エコシステムの シンプルな http(サーバー、クライアント)ライブラリです
Rustでいうところのhyperのようなものでしょうか。HTTPサーバーもクライアントもこれ一つで事足りるのは嬉しいので、今回はこのライブラリを採用しました。
該当commit: 0515724
ちなみに、他にもHTTPサーバーのフレームワークは存在するようですね。
exampleをコピペ
// https://github.com/http4s/http4s.g8/blob/30a5cb5f60345e7d51d7cc6c141badee3483ff95/src/main/g8/src/main/scala/%24package__packaged%24/%24name__Camel%24Server.scala#L14-L39
// 一部改変
def run[F[_]: Async: Network]: F[Nothing] = {
for {
client <- EmberClientBuilder.default[F].build
helloWorldAlg = HelloWorld.impl[F]
jokeAlg = Jokes.impl[F](client)
// Combine Service Routes into an HttpApp.
// Can also be done via a Router if you
// want to extract segments not checked
// in the underlying routes.
httpApp = (
Routes.helloWorldRoutes[F](helloWorldAlg) <+>
Routes.jokeRoutes[F](jokeAlg)
).orNotFound
// With Middlewares in place
finalHttpApp = Logger.httpApp(true, true)(httpApp)
_ <-
EmberServerBuilder.default[F]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(finalHttpApp)
.build
} yield ()
}.useForever
わけわからんfor
式ですね。<-
ってなんやねん!!!!とりあえずクライアント部分は消してHelloWorldRoutes
だけ持ってこようと頑張ったのですが、それだけで1時間ほどを要してしまいました。
該当commit: 518c48d
forってなんだ
これがわからないと進まないと思ったので早めに潰します。色々ggったら↓を見つけました。
ここからの解説は全くの私見であるため、何かしら間違っている可能性があります。:pray:
まず、<-
は.map
の糖衣構文的に束縛が行われます。<-
が複数登場した場合は直積的に展開され、場合に応じて.flatten
ないし.flatMap
の糖衣ともなる場合があります。
scala> val names = Seq("Scala", "H1rono K")
names: Seq[String] = List(Scala, H1rono K)
scala> for {
| name <- names
| n <- name.split(" ")
| } yield s"Hello, $n!"
res0: Seq[String] = List(Hello, Scala!, Hello, H1rono!, Hello, K!)
これはSeq[_]
だけではなくOption[_]
やEither[E, _]
などのF[_]
の形を取るもので、.map
, .flatMap
, etcを適用できるか否かによってfor
式で使える構文の幅が変わります。
このF[_]
にはMonadなりFunctorなりの適切な名称があるのでしょうが、私はそういった議論に疎いためここではF[_]
と一貫して呼びます
scala> for {
| home <- sys.env.get("HOME")
| hoge <- sys.env.get("HOGE")
| } yield s"HOME=$home; HOGE=$hoge"
res0: Option[String] = None
scala> for {
| home <- sys.env.get("HOME")
| user <- sys.env.get("USER")
| } yield s"HOME=$home; USER=$user"
res1: Option[String] = Some(HOME=/Users/kh; USER=kh)
これはRustの?
演算子(またはtry!
マクロ)が対応しているように見えます。
fn env_info() -> Option<String> {
use std::env::var;
let home = var("HOME")?;
let hoge = var("HOGE")?;
Some(format!("HOME={home}; HOGE={hoge}"))
}
なるほど、ここまでわかると結構使える気になりますね。
cats
このF[_]
をIOなどの副作用にも適用したのがCats Effectライブラリです。
どういうことかと言うと、println
をすんなり書けなくなります。
scala> import cats.effect._
import cats.effect._
scala> val proc: IO[Unit] = for {
| home <- std.Env[IO].get("HOME")
| _ <- std.Console[IO].println(s"HOME=$home")
| } yield ()
proc: cats.effect.IO[Unit] = IO(...)
scala> import cats.effect.unsafe.implicits.global
import cats.effect.unsafe.implicits.global
scala> proc.unsafeRunSync()
HOME=Some(/Users/kh)
損では?と思ったかもしれませんが、この制限が加わることでF[_]
で包まれていない文脈では副作用が発生しないことが保証されます。
http4sはtypelevelエコシステムのライブラリなので、もちろんcats effectに依存しています。ここまできてようやく、「送信されたリクエストをstdoutにダンプするエンドポイント」といったものも作れるようになりました。
trait DumpReq[F[_]] {
def dump(req: DumpReq.Req[F]): F[Status]
}
object DumpReq {
final case class Req[F[_]](req: Request[F]) extends AnyVal
def impl[F[_]: Async: Console]: DumpReq[F] = new DumpReq[F] {
def dump(req: Req[F]): F[Status] = for {
_ <- Console[F].println(req.req)
body <- req.req.as[String]
_ <- Console[F].println(body)
} yield Status.NoContent
}
}
該当commit: 991eab4
Either
を入れたい
ここまでで副作用をF[_]
の形で包むことができ、包まれた文脈での操作もはfor
式で簡潔に記述できるとわかりました。このF
には実際はIO
という型が当てはまります。これはプログラムのエントリポイントで与えられています。
// https://github.com/H1rono/BOT_Tartaglia/blob/991eab4beb617fd33ff783f7393c7ed19aeb266d/src/main/scala/h1rono/Main.scala#L7
val run = BotServer.run[IO]
ところで、Either[E, _]
やOption[_]
もF[_]
の一種です。RustでResult
, Option
に慣れた身としては寧ろこちらの方を積極的に使っていきたいところです。しかし、F[_]
のF
は既にIO
が占有してしまっています。どうすればいいのか?
In your case, if you want to keep the whole thing in a
for
comprehension, you can try theOptionT
monad transformer from Cats: https://typelevel.org/cats/datatypes/optiont.html.
ということで、これもcats製のライブラリが回答を用意していました。ありがとう...
該当commit: 07ef930
雑にthrow
などしていたところがOption
なりEither
なりで包まれて、かなりいい感じになりました。
今何の文脈だっけ?
ここまででF[_]
のF
にはIO
, EitherT
, OptionT
のいずれかを使用する場面のみ紹介してきました。しかし、実はもう一人F
に当てはまる役者が存在したのです。その名も...
これは何かというと、HTTPサーバー・クライアントを作成(ビルド)する際に包まれるやつです。つまり、exampleコードで紹介した部分です。
def run[F[_]: Async: Network]: F[Nothing] = {
for {
// Resource[IO, Client] のような型が返され、
// Resource[_, Client] が F[_] に対応する
client <- EmberClientBuilder.default[F].build
しかし型引数のF
にはIO
が与えられており、for
式で包まれているものと辻褄が合いません。何が暗躍しているのか?
for {
...
} yield ()
}.useForever
.useForever
お前か!!!!ということに気が付かずに時間を溶かしました。何も考えずに読み書きすると型が見えなくなってしまいますね。
該当commit: 5b4c976
そしてついに
ここまで長かった... 調べ始めてからこのcommitまで、休みを挟みつつ約2日かかりました。
その他困りごと
ここまでで一応やりたいことはできましたが、道中で色々と困ったので記しておきます。誰か助けてください。
マクロが使えない
BOT_Tartaglia/src/main/scala/h1rono/TraqClient.scala at 958ec62 · H1rono/BOT_Tartaglia #L69
Uri.fromString(s"https://q.trap.jp/api/v3$path").toOption.get
ここでuri
マクロを使いたかったんですが、何故かできませんでした。
scala-native-packager動いてない
BOT_Tartaglia/project/plugins.sbt at 958ec62 · H1rono/BOT_Tartaglia #L3
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")
この1行を書くだけでsbt universal:packageBin
でパッケージのビルドができるようになるって聞いたんですが何か足りてないんですかね。リポジトリのREADME読んでないのが悪い、はい...
nixでパッケージビルド
とりあえずflake.nixにはdevShellのみ用意しましたが、パッケージビルドはどうやるんでしょうか?typelevel/typelevel-nixとかを使うのかな?
なんかレスポンスが遅い
返事1つだけなのに、体感で30秒ほどかかっています。本当に謎
感想
scala, catsを通して関数型言語の本気に少し触れられたような気がします。途中あまりにもわからなくて、関数型言語もわからないのにプログラマーを自称することなどとても許されないと感じました。春休みも残り半分を切りましたが、Haskellの勉強をやってみようかなと思います。それと、終始GitHubのコード検索に助けられました。
最後に、ここまでで開いたタブ一覧(残っているもの)を置いておきます。
- https://scalameta.org/scalafmt/docs/configuration.html#scala-dialects
- https://scalameta.org/scalafmt/docs/installation.html#metals
- https://scalacenter.github.io/scalafix/docs/users/installation.html#sbt
- https://scalacenter.github.io/scalafix/docs/rules/overview.html
- https://scalameta.org/metals/docs/build-tools/sbt
- https://docs.scala-lang.org/getting-started/sbt-track/getting-started-with-scala-and-sbt-on-the-command-line.html
- https://github.com/NixOS/nixpkgs/blob/nixos-23.11/pkgs/development/tools/build-managers/sbt/default.nix#L49
- https://zendesk.engineering/using-nix-to-develop-and-package-a-scala-project-cadccd56ad06
- https://github.com/scala/scala/blob/2.13.x/build.sbt
- https://www.scala-sbt.org/1.x/docs/sbt-by-example.html
- https://www.scala-sbt.org/1.x/docs/Library-Dependencies.html#Library+dependencies
- https://www.scala-sbt.org/1.x/docs/Using-Plugins.html
- https://github.com/json4s/json4s?tab=readme-ov-file
- https://www.scala-lang.org/api/2.12.2/scala/io/BufferedSource.html
- https://stackoverflow.com/questions/1284423/read-entire-file-in-scala
- https://central.sonatype.com/artifact/org.typelevel/literally_2.12
- https://zenn.dev/110416/articles/168cb729101938
- https://http4s.org/v0.23/docs/json.html
- https://http4s.org/v0.15/api/org/http4s/client/index.html
- https://github.com/http4s/http4s.g8/blob/30a5cb5f60345e7d51d7cc6c141badee3483ff95/src/main/g8/src/main/scala/%24package__packaged%24/%24name__Camel%24Server.scala#L14-L39
- https://github.com/http4s/http4s/blob/series/0.23/examples/ember/src/main/scala/com/example/http4s/ember/EmberClientSimpleExample.scala
- https://typelevel.org/cats-effect/docs/std/env
- https://qiita.com/Ki-da/items/fb27f98b6152c90a9f86
- https://typelevel.org/cats-effect/api/2.x/cats/effect/Resource.html
- https://qiita.com/h_tyokinuhata/items/ab8e0337085997be04b1
- https://circe.github.io/circe/api/io/circe/Json.html
- https://docs.scala-lang.org/ja/overviews/core/string-interpolation.html
- https://qiita.com/fuzyco/items/772ff8452f03a7cb5582
- https://search.nixos.org/packages?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=scala
- https://github.com/NixOS/nixpkgs/blob/nixos-23.11/pkgs/development/tools/language-servers/metals/default.nix#L39
- https://github.com/NixOS/nixpkgs/blob/nixos-23.11/pkgs/development/compilers/scala/bare.nix#L30
- https://typelevel.org/cats/datatypes/optiont.html
- https://github.com/circe/circe/issues/751
- https://typelevel.org/blog/2018/10/06/intro-to-mtl.html
- https://zenn.dev/110416/articles/0c425ea061b945
- https://zenn.dev/gakuzzzz/articles/dd52cbed4df879
- https://www.scala-sbt.org/1.x/docs/Macro-Projects.html
- https://www.google.com/search?q=http4s+uri+scala.MatchError&sourceid=chrome&ie=UTF-8
- https://stackoverflow.com/questions/16321538/traits-with-immutable-paramiters-in-scala
- https://www.scala-sbt.org/1.x/docs/Forking.html
- https://qiita.com/jokester/items/f0e145acc41d13d9a615
- https://scala-text.github.io/scala_text/error-handling.html