Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Ktor × Exposed × Koin × kotlin-resultを用いたアプリケーションの実装

ここ数年で『関数型ドメインモデリング』という書籍や、『Functional and Reactive Domain Modeling』といった書籍を読んだ経験から、今業務で取り組んでいるKotlinではどう表現できるのかに興味がありました。年末年始に少しまとまった時間が取れたので、実際に実装してみました。今回は、その過程でどのような知見を得られたかを、主には自分の理解のためにまとめておきたいと思います。

github.com

先に書いておきますが、長いです。目次をご覧になって、興味のある場所をかいつまんでお読みください。

免責事項

筆者はKotlinを書き始めて半年くらいです。可能な限り調査をして一次情報を当たるなどし、情報の正しさの担保には努めるようにしましたが、事実誤認が少なからず含まれる可能性があります。また、説明は網羅的でない可能性はあり、遺漏が多々含まれることもあろうかと思います。あらかじめご了承ください。

参考にした書籍や資料などは、参考文献として掲載するようにしてあります。そちらも併せてご覧ください。

また、今回実装したアプリケーションは比較的小規模なものになります。筆者はKotlinで実務で大規模開発を行なっており、そこから予想されるシナリオなどを一応把握はしています。しかし、今回の成果物は仮想的なものに過ぎません。今回紹介する手法が業務上有用かどうかは現場でよく議論してから導入されることをおすすめします。

お題

mattnさんの記事にインスパイアされてTodoアプリを実装しました。

levtech.jp

データベースはPostgreSQLを使用しています。

技術スタック

最初に、使用した技術について説明しておきます。

  • Ktor: Kotlinでのバックエンド開発で用いられるフレームワークです。KotlinではSpring Bootがまず候補に上がるようですが、Spring系はもともとJavaフレームワークなのもあり、個人的にはPure Kotlinの方が好きなので、Ktorを選定することのほうが多いです。
  • kotlinx-serialization: JSONなどのデータ構造へのシリアライズ・デシリアライズに用いられるライブラリです。JSONに対して今回は使用しました。Protobufなどにも対応しているようです。
  • Exposed: いわゆるORMです。書き心地はScalaのSlickにとても近く、実はSlickを使ってプロダクトをリリースしたことがあるので、懐かしさすら覚えました。
  • Koin: DIコンテナを提供するライブラリです。Moduleと名のつく関数なりクラスなりを用意しておき、そこに依存関係の定義を記述します。
  • kotlin-result: KotlinでResult<T, E>型を提供するライブラリです。後述するように例外に型付けをしたいので、意図的に使用しています。
  • Kotest: Kotlinで使用できるテスティングフレームワークです。JUnitより記法が多くおすすめです。

個人的なライブラリに対する感想を記しておきます。

Ktorは、私の中では正直評価は微妙です。ExpressなどのRequest -> Responseの型遷移をさせるハンドラを用意させるだけのライブラリとは異なり、かなりDSLでカスタマイズされたハンドラを書くことになります。このDSLがとっつきやすいかとっつきにくいかは、正直評価のわかれるところだと思います。私は個人的にはこのデザインは嫌い寄りで、その関数が実はsuspendedであったり、引数や返り値などの型情報をDSLが完全に隠蔽している関係で、結果的に何をやっているかわかりにくくなっていると感じています。DSLで生産性が上がる場面があるのかもしれませんが、このDSLのデザインには懐疑的です。[*1]あと、何をするにもプラグインを導入することを求められますが、そもそもJSONくらいはデフォルトで何の苦労もなく返せてほしい。

Ktorのバージョンですが、3系ではなく2系を使用しています。この点についてですが、Ktor3系を使用していると、現時点で最新のKoinではNoClassDefFoundExceptionが発生してしまいます。こちらのIssueにも上がっていてすでにクローズはされているので、そのうち対応されるかと思いますが、噛み合わせが悪いようです。3系の機能でとくに積極的に使いたいものがあるわけでもないので、意図的に古いバージョンのものを使用しています。

kotlinx-serializationは、私の中では評価は微妙です。Kotlinにはマクロがないので仕方がない節は多そうですが、実行時に解決される話が多すぎるように見えました。RustのSerdeに飼い慣らされてしまっているので、まったく物足りなかったです。ただ、アノテーションをつけるだけでしっかり欲しい機能に対応できるのはいい点だと思います。業務でJacksonを用いていますが、さすがにいろいろ冗長だと感じることが多いためです。

Exposedは、Slickに慣れていれば軽くドキュメントを読むだけで使いこなせました。また、トランザクションの切り出し方がなかなかいい感じになっており、このあと説明するように、DDDというかオニオンアーキテクチャを実装する際にレイヤーの関係性を壊しにくく非常に有用でした。一方で、suspendへの対応が甘く(ORMなのだからちゃんとやってほしい)、たとえば例外が親のcoroutineにちゃんと伝播してくれないなどのバグが潜んでいそうに見えています。詳しく検証していないので、この点については導入時に現場で検証されることをおすすめします。

Koinはあまり言うことはないです。ハマりどころも少なかったように思いました。簡単なアプリケーションを実装する程度であれば、このライブラリで十分そうでした。Ktorとの噛み合わせも悪くなかったです。Ktor3系への対応がまだ足りておらず、3系と組み合わせると例外を吐いてますが…。

kotlin-resultは、個人的にはほしい機能は揃っているように見えました。bindingで処理をフラットに書いていけるのはいい点ですね。コルーチンにも対応していて、coroutineBindingと切り替えるだけで利用できます。Arrow-KtのEitherと比較されることも多いでしょうが、ほとんど好みの差だと思います。Arrow-Ktを入れるかkotlin-resultを入れるかは、Arrow-Ktの他の機能が欲しいかどうかで決まるでしょう。モナモナした純粋関数型プログラミングをKotlinで行いたいというケースを除いて大半のケースでは不要だと思うので[*2]、モジュールサイズなどを考えるとkotlin-resultをおとなしく利用しておくのが吉かもしれません。型パズル対策のzipOrAccumulateなども揃っています。

アプリケーション以外の周辺ツールでは、マイグレーション部分にsqldefを使用しています。このツールはRubyのRidgepoleのような機能を提供しています。CREATE TABLE文を書き続けることでALTER TABLEを生成してくれます。そもそも私がFlywayよりRidgepoleが好きでRubyでないプロジェクトでも入れていたレベルなんですが、どうせならバイナリポンで動いてくれるこちらを使うことにしました。便利なので使ってください。

設計

さて、長くなってしまいましたが本題に入りましょう。設計面でいくつか言及しておきたいと思います。

全体的な設計

DDDを標榜するプロジェクトでよく使用される型をそのまま再現しています。下記の図のようなレイヤー関係になっています。

レイヤー

QueryServiceはデータベースへの読み取り処理を主に担っています。たとえばToDoリストを全部取得したいというようなユースケースがあると思いますが、そうした取得系はクエリサービスで完結させています。クエリサービスはクエリの発行を直接行います。

ApplicationServiceはデータベースへの書き込み処理が発生する場合、呼び出されます。たとえばToDoリストへの新しいタスクの登録、更新、ToDoリストからのタスクの削除などがこれに該当します。アプリケーションサービスでは、一度ドメインモデルを構築しておき、そのモデルを使ってリポジトリを経由してデータベースへの書き込み処理が走るようになっています。

Kotlinの使用に関するもの

データ型の定義にはdata classを使う

便利なので使いましょう。equalsやhashCodeなどの実装は不要です。また、copyメソッドなどが生えます。

data class ValidatedTodo private constructor(
    val id: TodoId,
    val title: String1024,
    val description: String2048?,
    val due: TodoDue?,
    val status: TodoStatus,
    val createdAt: OffsetDateTime,
    val updatedAt: OffsetDateTime
) 

一点注意ですが、現状のKotlinにはprivate constructorを使用する場合、copyの可視性周りにバグがある点に注意です。Kotlin 2.0.20あたりから徐々にcopyの使用に制限がついていく形にはなるようです。私はそもそも使用しなければいいと思っているので、コンパイラオプションでまったく使用できなくしました。[*3]

// Shut up calling `copy` function against data classes that have private constructors.
tasks {
    named("compileKotlin", KotlinCompilationTask::class.java) {
        compilerOptions {
            freeCompilerArgs.add("-Xconsistent-data-class-copy-visibility")
        }
    }
}

フィールドひとつの値オブジェクトの表現にはvalue classを使う

値オブジェクトは多くのケースではフィールドをひとつしか持たないと思います。こうしたケースではvalue classを積極的に利用したいところです。value classはオーバーヘッドなしで、別の型名を与えることができる機能です。型エイリアスとは異なり、エイリアスではないのでコンパイル時には別の型としてみなされます。data classでもやれなくはないのですが、data classでラップした分のオーバーヘッドが乗ることになります。value classを使用すると、このオーバーヘッドなくして別の型を定義できます。

下記では、TodoIdという型はコンパイル時にはUUID型とまったく同じと解釈されます。

@JvmInline
value class TodoId(val value: UUID)

スコープ関数は正しく利用する

Kotlinにはletrunなどの「スコープ関数」と呼ばれる便利関数群があります。これが用意されているのは、Kotlinでは{}() -> Tの関数ブロックとして定義している手前、ブロックスコープを利用できないからでしょう。ブロックスコープを利用したい場合、これらの利用を検討するとよいと思っています。

ブロックスコープをはじめとするこうしたスコープを細かく切る機能を利用したい理由は、バグの低減です。これは経験則になりますが、昔上司が「バグりやすいコードはだいたい変数のスコープが長すぎる」と言っていたことを思い出します。変数のスコープを短く切ったり、余計な情報をスコープ外に漏らさないことは、読みやすかったりメンテナンスしやすかったりするコードの要素のひとつになりえるでしょう。パフォーマンスの面では、変数のスコープが正しく管理されていたり短かったりすると変数の寿命が早めに確定するので、コンパイラやリソース節約にもいい影響を与えます。

そういうわけで、スコープ関数は使える場面では使うようにしました。解説記事を読んでいるとnullableハンドリングのためのlet以外非推奨としている記事も見かけましたが、それはもったいないかなと思いました。

たとえば、runは次のような箇所で使用しています。ここでは、読み取り専用と書き込み可能なデータベース接続のふたつを生成している箇所です。似たような処理が連続していることがわかります。

internal fun Application.establishDatabaseConnection(cfg: ApplicationConfig): Pair<DatabaseConn<Permission.ReadOnly>, DatabaseConn<Permission.Writable>> {
    val read = cfg.run {
        val jdbcUrl = property("db.read.jdbcURL").getString()
        val user = property("db.read.user").getString()
        val password = property("db.read.password").getString()
        val driverName = property("db.read.driverClassName").getString()
        DatabaseConn.establishRead(Database.connect(jdbcUrl, driver = driverName, user = user, password = password))
    }
    val write = cfg.run {
        val jdbcUrl = property("db.write.jdbcURL").getString()
        val user = property("db.write.user").getString()
        val password = property("db.write.password").getString()
        val driverName = property("db.write.driverClassName").getString()
        DatabaseConn.establishWrite(Database.connect(jdbcUrl, driver = driverName, user = user, password = password))
    }
    return Pair(read, write)
}

これを仮にスコープ関数なしで書いたとすると、次のように変数名をずらす必要が出てきます。が、たとえばread側の情報はwrite側では一切不要なので、スコープで切ってそもそも参照できないようにしたくなります。他のプログラミング言語であればブロックスコープの出番ですが、Kotlinにはないのでrun関数を利用しているというわけです。

internal fun Application.establishDatabaseConnection(cfg: ApplicationConfig): Pair<DatabaseConn<Permission.ReadOnly>, DatabaseConn<Permission.Writable>> {
    val readJdbcUrl = cfg.property("db.read.jdbcURL").getString()
    val readUser = cfg.property("db.read.user").getString()
    val readPassword = cfg.property("db.read.password").getString()
    val readDriverName = cfg.property("db.read.driverClassName").getString()
    
    val read = DatabaseConn.establishRead(Database.connect(readJdbcUrl, driver = readDriverName, user = readUser, password = readPassword))
    
    val writeJdbcUrl = cfg.property("db.write.jdbcURL").getString()
    val writeUser = cfg.property("db.write.user").getString()
    val writePassword = cfg.property("db.write.password").getString()
    val writeDriverName = cfg.property("db.write.driverClassName").getString()
    
    val write = DatabaseConn.establishWrite(Database.connect(writeJdbcUrl, driver = writeDriverName, user = writeUser, password = writePassword)
    
    return Pair(read, write)
}

エラー型の表現はsealed classで行う

エラー型を値として扱いたいと言うのもありますが、そもそもKtorについている例外ハンドリング機構をいい形で利用するためには、 when を用いたエラーハンドリングを前提とする必要があります。これを有効活用するための方法として、sealed classを利用する手が考えられます。sealed interfaceでないのは、このあと説明するようにThrowableとの噛み合わせの問題です。

今回実装した方法では、まずsharedと呼ばれる場所(どのレイヤーでも共通して使用するようなものを定義する)に下記のようにルートとなるエラー型を定義しています。これがThrowableを継承する形になっています。Throwableの制約が必要なのはThrowableを継承しておくと、後述するkotlin-resultのResult型でいくつか有用な関数を利用できるようになるためです。

open class AppErrors(message: String?, cause: Throwable?) : Throwable(message, cause)

次に、各レイヤーのエラー型を定義します。たとえばでdomainレイヤーのエラー定義を示します。sealed class名前空間を切った後、data classで個別のデータ型を定義しています。

package com.github.yuk1ty.todoAppKt.domain.error

import com.github.yuk1ty.todoAppKt.shared.AppErrors

sealed class DomainErrors(why: String) : AppErrors(message = why, cause = null) {
    data class ValidationError(val why: String) : DomainErrors(why)
    data class ValidationErrors(val errors: List<ValidationError>) : DomainErrors(errors.joinToString(", "))
}

今回のエラー型は最終的にはKtorのエラーハンドラの機構で、対応するステータスコードを返すようハンドリングされます。このときsealed classを使って定義しておくのが生きてきます。whenでマッチ状況を確認しながらハンドリングします。こうすることで、仮に新しいエラー型が追加されたとしても、whenのもつ網羅性チェックによりコンパイルエラーとなるため追加忘れに気づけるようになります。

package com.github.yuk1ty.todoAppKt.shared.modules

import com.github.yuk1ty.todoAppKt.adapter.error.AdapterErrors
import com.github.yuk1ty.todoAppKt.api.error.HandlerErrors
import com.github.yuk1ty.todoAppKt.application.error.ApplicationServiceErrors
import com.github.yuk1ty.todoAppKt.domain.error.DomainErrors
import com.github.yuk1ty.todoAppKt.shared.AppErrors
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*

internal fun Application.registerExceptionHandlers() {
    install(StatusPages) {
        exception<AppErrors> { call, cause ->
            when (cause) {
                is HandlerErrors.InvalidPathParameter -> call.respond(HttpStatusCode.BadRequest)

                is ApplicationServiceErrors.EntityNotFound -> call.respond(HttpStatusCode.NotFound)

                is DomainErrors.ValidationError -> call.respondText(
                    status = HttpStatusCode.BadRequest,
                    text = cause.why
                )

                is DomainErrors.ValidationErrors -> call.respondText(
                    status = HttpStatusCode.BadRequest,
                    text = cause.errors.joinToString(", ")
                )

                is AdapterErrors.DatabaseError -> {
                    call.application.environment.log.error("Database-related error happened", cause.cause)
                    call.respond(HttpStatusCode.InternalServerError)
                }
            }
        }
    }
}

enum classで定数チックに扱う手もありそうですが、Throwableを継承できません。Result型の利用を前提とするなら単に不便なので、Throwableを継承できるsealed classを利用したほうがいいと思います。

アプリケーション全体の非同期化

国内外問わずネット上の情報を見ると、Webアプリケーションのサンプル実装であってもブロッキング処理を行っているものが散見されます。Rustだとasync/awaitならびにtokioを使用するノンブロッキング処理がほぼ前提になっているのであまり気にすることはありませんでしたが、アプリケーションの非同期化は、ユーザー数の多いアプリケーションを構築するのであればわりと最初にやる話なので、その辺りがコミュニティ全体ではどう考慮されているのかは気になっています。

さすがに非同期化は前提として扱ったほうがよさそうに思ったので、今回構築したアプリケーションでは、必要な箇所はほぼすべてsuspend関数を使用しています。I/Oが発生する箇所にはほぼ確実に必要になるため導入されていますが、逆に言うと、たとえばドメインモデルのようにI/Oを行わない箇所には不要なのでつけてはいません。

顕著なのはアプリケーションサービスなので例を示しておきます。アプリケーションサービスのほぼすべての関数には、次のように先頭にsuspendキーワードがついています。これは、中のトランザクション発行処理がやはりsuspendなことに引っ張られています。なお後述しますが、使用しているライブラリ側のバグと思われる事象により、本来トランザクションの発行やリポジトリの関数はやはりsuspendになるべきなのですが、現状は同期処理としています。ブロッキングI/O専用のスレッドに逃しています。

class TodoApplicationService(
    private val conn: DatabaseConn<Permission.Writable>,
    private val repository: TodoRepository
) {
// ...
    suspend fun updateTodo(command: TodoCommands.Update): Result<Unit, AppErrors> =
        conn.tryBeginWriteTransaction {
            binding {
                val existingTodo =
                    repository.getById(TodoId(command.id))
                        .toErrorIfNull { ApplicationServiceErrors.EntityNotFound(command.id) }
                        .bind()
                val toBeUpdated = UnvalidatedTodo(
                    id = existingTodo.id.value,
                    title = command.title ?: existingTodo.title.value,
                    description = command.description ?: existingTodo.description?.value,
                    due = command.due ?: existingTodo.due?.value?.toLocalDateTime(),
                    status = command.status ?: existingTodo.status.asString(),
                    createdAt = existingTodo.createdAt.toLocalDateTime(),
                    updatedAt = LocalDateTime.now()
                ).let { ValidatedTodo(it) }.bind()

                repository.update(toBeUpdated)
            }
        }
// ...

余談: suspendとasync/await

ところでsuspend関数周りを調査していると、普通にsuspend関数を呼び出す例と、asyncawaitを使った例のふたつに出会います。たとえばKotlin in Action Second Editionではsuspend関数の説明の後にasyncとawaitが解説されています。違いを押さえておく必要があると思うので(実際どちらをどう使うか迷った)、簡単に私の理解を述べておきます。

両者は並行処理にまつわる機能ですが、評価戦略に違いがあります。suspend関数に何も加工しない状態では即時評価が行われます。一方で、suspend関数をasyncで囲むと遅延評価が行われます。この場合、後述するようにawait()関数を呼び出すまで評価が走りません。

suspend関数単体では即時評価が行われるようです。つまり、その関数が呼び出されたタイミングです。awaitが呼び出されると関数の評価が走り、計算が終われば値が確定します。[*4]たとえば次のコードを確認すると、myFirstmySecondの二つの変数に値が代入された時点で、200msの待ちが入りslowlyAddNumbersの結果が確定しています。これは内部に仕込んであるログの流れを見ても顕著です。

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.time.Duration.Companion.milliseconds

suspend fun slowlyAddNumbers(a: Int, b: Int): Int {
    println("Waiting a bit before calculating $a + $b")
    delay(100.milliseconds * a)
    return a + b
}

fun main() = runBlocking {
    println("Starting the suspend computation")
    val myFirst = slowlyAddNumbers(2, 2)
    val mySecond = slowlyAddNumbers(4, 4)
    println("Waiting for suspended value to be available")
    println("The first ${myFirst}")
    println("The second ${mySecond}")
}

// ===
// 下記は出力結果
// ===

// Starting the suspend computation
// Waiting a bit before calculating 2 + 2
// Waiting a bit before calculating 4 + 4
// Waiting for suspended value to be available
// The first 4
// The second 8

一方で、async {}await()を使うと遅延評価が行われます。asyncで囲まれた関数が2つの変数に代入されたタイミングでは評価は走らず、await()を行ったタイミングで評価が走ります。「Waiting for suspended value to be available」というメッセージの後ろに、関数内の出力内容が出ていることを確認できるでしょう。

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.async
import kotlin.time.Duration.Companion.milliseconds

suspend fun slowlyAddNumbers(a: Int, b: Int): Int {
    println("Waiting a bit before calculating $a + $b")
    delay(100.milliseconds * a)
    return a + b
}

fun main() = runBlocking {
    println("Starting the async computation")
    val myFirst = async { slowlyAddNumbers(2, 2) }
    val mySecond = async { slowlyAddNumbers(4, 4) }
    println("Waiting for suspended value to be available")
    println("The first ${myFirst.await()}")
    println("The second ${mySecond.await()}")
}

// ===
// 下記は出力結果
// ===

// Starting the async computation
// Waiting for suspended value to be available
// Waiting a bit before calculating 2 + 2
// Waiting a bit before calculating 4 + 4
// The first 4
// The second 8

async {}Deferred<T>という型を返すわけですが、これがミソになります。この型は他のプログラミング言語ではPromiseFutureなどという型で表現されていることが多いでしょう。こうした型は、「将来のある時点で値を確定させる」意味合いを含んでいます。将来のある時点とはつまりawaitが走るタイミングで、ここまではブロック内部の評価を走らせないよう遅延させるということが起こります。[*5]

両者の使い分けについてです。まず間違いなくasync/awaitを使う例から行くと、タスク(つまりひとつひとつのsuspend fun)を同時に呼び出したい場合に使用するといいようです。たとえば、最終的に.awaitAll()という関数を呼び出したいパターンではこちらを使用せざるを得ないです。これは複数のDeferred<T>を同時に走らせ、すべての値が出揃うまでの合流待ちをさせる機能を持つ関数です。このように複数タスクを一度に実行させたいケースではasyncを使うといいという旨の記述が『Kotlin in Action』にはありました[*6]。それ以外のケースでは素直にsuspend関数をそのまま呼び出しておく、で基本的には問題ないようです。

このあたりは、あまり調べても体系的な解説が出てこなかった関係で、自分の思考の整理も兼ねて後日記事を書こうと思っています。

ライブラリの使用に関するもの

全部Result型で通す

さまざまな記事や事例を拝見しましたが、レイヤー単位で例外とResult型によるハンドリングとを切り替えている実装が多いようです。私は例外はどうしても必要でない限り不要だと思っているので、全部Result型で通したほうが方針としてはすっきりすると思いました。また、関数の純粋性の担保の観点からも、可能な限り例外は避けたほうがいいと思っています。例外は代表的な副作用です。論理的に正しい型遷移を十分に示すためにも、どのレイヤーであっても全部Result型で通すほうがいいと思います。

これが意味するところは、initブロックやrequireを使用しないということです。使用しないことによりどれくらいのコストが追加でかかるようになるのかが、実は今回の裏テーマでした。やってみた感想としては、使用しないことそれ自体のコストは単に例外を投げなくするだけなのでほとんど感じないものの、呼び出し側のResultのハンドリングに慣れが必要だと思いました。少なくとも、private constructorinvokeを組み合わせつつ実装すれば、initrequireは利用せずとも困ることはないとも思いました。

今回の実装では、最終的なドメインモデルとしてのTodoValidatedTodoという型で表現されます。このValidatedTodoは、UnvalidatedTodoを受け取り、その値をすべて検査して、検査をすべて通過すれば生成することができます。型の遷移としては、UnvalidatedTodo -> Result<ValidatedTodo, DomainErrors>となります。下記はValidatedTodoの実装です。

data class ValidatedTodo private constructor(
    val id: TodoId,
    val title: String1024,
    val description: String2048?,
    val due: TodoDue?,
    val status: TodoStatus,
    val createdAt: OffsetDateTime,
    val updatedAt: OffsetDateTime
) {
    companion object {
        operator fun invoke(
            unvalidatedTodo: UnvalidatedTodo
        ): Result<ValidatedTodo, DomainErrors.ValidationErrors> = unvalidatedTodo.run {
            zipOrAccumulate(
                { String1024(title) },
                { description?.let { String2048(it) } ?: Ok(null) },
                { TodoStatus.fromString(status) },
            ) { validatedTitle, validatedDescription, validatedStatus ->
                ValidatedTodo(
                    id = TodoId(unvalidatedTodo.id ?: UUID.randomUUID()),
                    title = validatedTitle,
                    description = validatedDescription,
                    due = due?.let { TodoDue(it.atOffset(ZoneOffset.UTC)) },
                    status = validatedStatus,
                    createdAt = createdAt.atOffset(ZoneOffset.UTC),
                    updatedAt = updatedAt.atOffset(ZoneOffset.UTC)
                )
            }.mapError { DomainErrors.ValidationErrors(it) }
        }
    }
}
Railway-Oriented Programming

Result型は、内部に持つ値を「エラーになるかもしれない」という文脈で包んだ型です。この文脈というのがやっかいで、中身を取り出すためにはwhenを使ってパターンマッチングもどきをかけるか、mapflatMapなどの専属の関数を使って中身の値に対する操作をかける必要があります。

mapflatMapなどは、成功の場合は引数として渡された関数をそのまま実行し、次も成功であれば同様に引数の関数を実行し、という操作をパイプラインのようにつなげて実行させます。このパイプライン内で一度でもエラーが発生すると、発生箇所以降では全部そのエラーをパイプラインの最後まで伝播させます。これをRailway-Oriented Programmingというようです。参考になりそうな記事

ただ、flatMapの多重使用は多重ネストを生みやすい問題があります。そこで回避策として上がるのが、kotlin-resultの持つbindingという機能です。これを利用すると、flatMapの多重ネスト問題を回避できます。下記にbindの利用例を示します。

    suspend fun updateTodo(command: TodoCommands.Update): Result<Unit, AppErrors> =
        conn.tryBeginWriteTransaction {
            binding {
                val existingTodo =
                    repository.getById(TodoId(command.id))
                        .toErrorIfNull { ApplicationServiceErrors.EntityNotFound(command.id) }
                        .bind()
                val toBeUpdated = UnvalidatedTodo(
                    id = existingTodo.id.value,
                    title = command.title ?: existingTodo.title.value,
                    description = command.description ?: existingTodo.description?.value,
                    due = command.due ?: existingTodo.due?.value?.toLocalDateTime(),
                    status = command.status ?: existingTodo.status.asString(),
                    createdAt = existingTodo.createdAt.toLocalDateTime(),
                    updatedAt = LocalDateTime.now()
                ).let { ValidatedTodo(it) }.bind()

                repository.update(toBeUpdated)
            }
        }

bindを使うと、Scalaのfor-yieldのようにflatMapを宣言的に記述できるようになります。処理の見通しがよくなったり、余計な型を作ってflatMapのパイプラインをがんばって伝播させる必要がなくなるなどのメリットがあると思います。デメリットは、多少慣れが必要なことです。

例外的に例外を投げるとき

さて、「例外がどうしても必要でない限り」と先ほど書きました。実はそうしたケースがいくつかあります。今回のアプリケーションでは、Ktorのハンドラ内と、Exposedのトランザクション内です。Ktorは先ほども説明したように最終的に500 Internal Server Errorとしたり、設定したステータスでハンドリングさせたい場合に、どうしても例外を投げる必要があります。Exposedのトランザクション発行機能は、トランザクション内部の処理で例外が投げられるとロールバックが走るようになっています。これらはきちんと利用しないと逆に不具合を生むので、意図的に例外を投げるようにしています。

下記はKtorのハンドラの例です。getOrThrow関数を呼び出している箇所が見られると思いますが、ここで投げた例外はKtorのエラーハンドリング機構を経由して適切なステータスコードに変換されます。

    get<Todos.GetAll> {
        coroutineBinding {
            val allTodos = todoQueryService.getTodos().bind()
            val res = allTodos.map { TodoResponse.fromTodo(it) }

            call.respond(HttpStatusCode.OK, res)
        }.getOrThrow()
    }

また、下記はExposedのトランザクション発行周りです。statementには実際のクエリ発行部分が入ります。Exposedのクエリ発行部分は、いくつか例外を投げる可能性があるため、transaction関数のスコープ関数内では一旦例外を投げています。投げられた例外はtransaction関数本体でロールバック処理を行った後再度伝播して投げられてくるので、投げられてきた例外をkotlin-resultのrunCatchingで拾ってResult型に再び変換しています。これにより、beginReadTransaction関数を呼び出す側では例外が投げられたかどうかを気にすることはありません。

余談ですが、statementブロッキング処理になっているので、Dispatchers.IOブロッキング処理を逃しています。これによりこの関数はsuspendになっています。ちなみに後述しますが、この対応は不十分ではないかと考えています。

suspend fun <T> DatabaseConn<Permission.ReadOnly>.beginReadTransaction(statement: Transaction.() -> T): Result<T, AppErrors> {
    val conn = this.inner
    return withContext(Dispatchers.IO) {
        runCatching {
            transaction(
                db = conn,
            ) {
                statement()
            }
        }.mapError { AdapterErrors.DatabaseError(it) }
    }
suspend関数との組み合わせ

アプリケーション全体を非同期処理で設計していると、bindingでは対応できません。というのも、bindingの定義は次のようになっており、suspend関数を受け付け可能ではないからです。

public inline fun <V, E> binding(crossinline block: BindingScope<E>.() -> V): Result<V, E>

そこで利用できるのがcoroutineBindingです。この関数はsuspend関数を受け付けできるようになっています。.bind()の呼び出しは変えることなく、外側のみcoroutineBindingとするだけで移行は完了します。今回の実装では、ハンドラの部分でこのcoroutineBindingがよく利用されています。

private fun Route.todoHandler() {
    // ...

    get<Todos.GetAll> {
        coroutineBinding {
            val allTodos = todoQueryService.getTodos().bind()
            val res = allTodos.map { TodoResponse.fromTodo(it) }

            call.respond(HttpStatusCode.OK, res)
        }.getOrThrow()
    }
}

コンストラクタインジェクションを利用する

Koinを使用している関係で、コンストラクタインジェクションを使用しています。関数単体で見ると確かに純粋性が…という話になってしまう気はしますが、ここは気にしないことにしました。このほうがKotlinにとっては読みやすいかなと思っているためです。

関数型プログラミングを利用していると、DIをするためにカリー化や高階関数、Readerモナドを利用することがあります。また、書籍などを読んでもそう紹介されていることが多そうです。しかし、Kotlinではこうした機能をネスト少なく読みやすく扱える機構がないように思っており、素直な実装からは程遠い超絶技巧が必要になることがあります。やってみてもおもしろかったのですが、実用性を鑑みるとコンストラクタインジェクションに軍配があがりそうだったので、今回は採用しませんでした。

class TodoApplicationService(
    private val conn: DatabaseConn<Permission.Writable>,
    private val repository: TodoRepository
) { ... }

幽霊型で権限を表現する

データベースの接続を読み取りと書き込みでわけているケースはままあると思います。一応その辺りを意識しておくと実装例がより実用的になるかと思い、このようにしました。

この場合、データベースの接続は「読み取り専用」と「書き込み可能」のふたつの属性にわかれることがわかります。この属性情報は型付けしておくと、データベースの接続先の取り違えをコンパイル時に防ぐことができるようになります。読み取りと書き込みの権限情報を型付けするということです。

ここで役に立つのは幽霊型という型付け方法です。幽霊型はジェネリクスの位置に型を入れてはおくものの、そのジェネリクスの型は実際には利用されないので、コンパイル時には型情報が消去されます。この消去されるさまが、幽霊が消えるかのようになくなってしまうことから「幽霊型」と名付けられているようです。

Kotlinではやはり幽霊型を利用できるため、データベース接続情報もこれを利用して権限ごとに型付けしました。

sealed interface Permission {
    data object ReadOnly : Permission
    data object Writable : Permission
}

@JvmInline
value class DatabaseConn<K : Permission>(val inner: Database) {
    companion object {
        fun establishRead(conn: Database): DatabaseConn<Permission.ReadOnly> = DatabaseConn(conn)
        fun establishWrite(conn: Database): DatabaseConn<Permission.Writable> = DatabaseConn(conn)
    }
}

クエリサービスでは読み取り、アプリケーションサービスでは書き込みが想定されます。この想定を反映した実装は下記のようになります。

// TodoQueryService
class TodoQueryService(private val conn: DatabaseConn<Permission.ReadOnly>)
// TodoApplicationService
class TodoApplicationService(
    private val conn: DatabaseConn<Permission.Writable>,
    private val repository: TodoRepository
)

幽霊型はわざわざ用いる必要はないかもしれません。Kotlinの場合、interfaceを用意しておきつつ、個別のdata classを用意するだけで十分ではあります。この方針では、たとえば次のように実装できるでしょう。実用上はこれでもいいとは思います。

sealed interface DatabaseConn {
    val conn: Database
}

data class ReadOnlyDatabaseConn(override val conn: Database) : DatabaseConn

data class WritableDatabaseConn(override val conn: Database) : DatabaseConn

幽霊型を用いるメリットは、型情報の節約でしょうか。先に示した実装例だとReadDatabaseConnectionWritaDatabaseConnectionのふたつの型が必要でした。幽霊型を用いると、DatabaseConnectionだけ用意しておけばよいことになります。今回の実装ではそこまで恩恵を感じられませんが、フィールドの増減がままあるデータに対して幽霊型を用いると、実装コストの削減につながるかもしれません。

また、特定の型になったタイミングで特定の関数を呼び出せるようにしたいケースでも有用です。よく紹介される例としてはビルダーパターンを幽霊型で実装する手があります。ビルダーパターンではすべてのメソッドを呼び出したかや、ビルド後の型を生成する関数をどのタイミングで呼び出すかは、基本的にユーザーに委ねられることになります。しかし幽霊型を組み合わせると、そうしたミスの発生しそうな箇所をコンパイル時に検査させることができます。

今回のケースでは、両者の用例にとくに当てはまらないので、普通にinterfaceを用意するパターンで実装したとしても同じような結果を得られたのかもしれません。一応トランザクションの発行部分で拡張関数を用いているので、完全に無駄になっているわけではないのですが。

suspend fun <T> DatabaseConn<Permission.ReadOnly>.beginReadTransaction(statement: Transaction.() -> T): Result<T, AppErrors> {
    val conn = this.inner
    return withContext(Dispatchers.IO) {
        runCatching {
            transaction(
                db = conn,
            ) {
                statement()
            }
        }.mapError { AdapterErrors.DatabaseError(it) }
    }
}

suspend fun <T> DatabaseConn<Permission.Writable>.tryBeginWriteTransaction(statement: Transaction.() -> Result<T, AppErrors>): Result<T, AppErrors> {
    val conn = this.inner
    return withContext(Dispatchers.IO) {
        runCatching {
            transaction(
                transactionIsolation = Connection.TRANSACTION_READ_COMMITTED,
                db = conn,
            ) {
                statement().getOrThrow()
            }
        }.mapError { AdapterErrors.DatabaseError(it) }
    }
}

トランザクションの切り出しどころ

トランザクションはどこで扱われるべきか?問題

データベースアクセスするにあたり、トランザクションをどのレイヤーで切り出すかは悩みどころのようです。よくある実装ではRepositoryのインターフェースを切っておき、それをドメインレイヤーに置いておきます。実装はインフラレイヤーに置いておき、ドメインにインフラの情報が漏れ出すことを防ぐものが見られます。このとき、トランザクションはどう扱えばいいでしょうか?

よくやる手のひとつには、インフラレイヤーでトランザクションを切るというパターンがあり得そうです。この場合、何個もテーブルに対して更新をかけるようなケースでリポジトリをどう切り分けるかが問題になるわけですが、そもそも集約がトランザクションの単位であったことを思い出し、集約を一つ切り出して、集約に対応するリポジトリ内に各テーブルに対する更新をすべて書き切ってしまうという手がありそうです。ただこれは集約が大きくなりがち問題が発生する傾向にはあります。濫用防止のための努力が求められるでしょう。

もうひとつよくやる手として、いわゆるアプリケーションサービスでトランザクションを発行し、複数リポジトリにまたがるトランザクションを管理するというものがあります。今回私が行った実装ではこの手法をとっています。この手法のいいところは、集約などの大きめのデータにすべてをまとめておかずとも、細かい単位のままでデータベースへの書き込み処理をかけられる点です。

ですがここで問題になるのが、トランザクションの情報をどう扱うかというものです。たとえばトランザクションTransactionという型で表現され、それをインフラレイヤーで使われるであろうデータベース操作のライブラリにまで引き渡す必要があったとします。すると、ドメインレイヤーのinterfaceリポジトリシグネチャを次のように書き換える必要が出てくるでしょう。

interface TodoRepository {
    fun create(validatedTodo: ValidatedTodo, tx: Transaction): Result<Unit, DomainErrors.ValidationErrors>
}

ただしこれはドメインレイヤーにインフラレイヤーの都合が漏れ出していることになります。これを許容するかどうかは現場によりそうです。事例を調べてみると、この辺りは諦めている現場が多そうに見えてきます。たとえばこの記事では、ドメインレイヤーのリポジトリのインタフェースにトランザクションの情報が入り込むことを許容しているようです。

私の昔の同僚が以前つぶやいていたのですが、アプリケーションレイヤーにリポジトリのインタフェースを設置する手があります。アプリケーションレイヤーにインタフェースを置いておき、インフラレイヤーで具体的な実装を行うという方法です。こうすると、ドメインレイヤーにインフラの情報が一切漏れ出すことはなくなります。割と理想系かもしれません。最近読んだ下記の本でも、アプリケーションレイヤーにインタフェースを置いていそうでした。[*7]今回はこの手法を採用しています。

アプリケーションレイヤーにリポジトリinterfaceを置くのは、ドメインモデルをさまざまな副作用から解放する意味でも合理的だと考えています。たとえばドメインレイヤーにリポジトリinterfaceを置くと、ドメインレイヤーにsuspendDeferredなどの副作用を含む情報が置かれることになります。これはドメインを純粋に保つ観点からは避けたいでしょう。

また、そもそもDIPが本当に必要かは検討しなおしてもよいかもしれません。Kotlinのモックライブラリではそもそもobjectに対してもモックをさせたりしますし、テストでモックを利用しないのであれば、インタフェースをわざわざ切り出す必要すらないかもしれません。インタフェースに切り出すことで、データベースバックエンドの切り替えによる影響を吸収させたいという目的を達成できるかもしれませんが、そもそもそうした切り替えはそこまで大きな頻度で起こらないかもしれません。

Exposedのトランザクションの発行の仕方がいい話

話は少し逸れてしまいましたが、Exposedの場合はこの点を考慮する必要はまったくありませんでした。リポジトリのインタフェースを置いておいて、それで終わりでした。というのもtransaction関数がよくできており、アプリケーションレイヤーで発行後、transaction関数のブロック内にあるすべての処理はひとつのトランザクションとしてまとめられるからです。つまり関数のシグネチャトランザクションの情報が飛び出てくることはありません。

まず、今回実装したアプリケーションレイヤーのリポジトリは次のようになっています。トランザクションに関連するシグネチャは一切現れていません。[*8]最悪、ドメインレイヤーに置いても問題ないかもしれません。

package com.github.yuk1ty.todoAppKt.application.repository

import com.github.michaelbull.result.Result
import com.github.yuk1ty.todoAppKt.domain.model.TodoId
import com.github.yuk1ty.todoAppKt.domain.model.ValidatedTodo
import com.github.yuk1ty.todoAppKt.shared.AppErrors

interface TodoRepository {
    fun getById(id: TodoId): Result<ValidatedTodo?, AppErrors>
    fun create(validatedTodo: ValidatedTodo): Result<Unit, AppErrors>
    fun update(validatedTodo: ValidatedTodo): Result<Unit, AppErrors>
    fun delete(id: TodoId): Result<Unit, AppErrors>
}

次にアプリケーションレイヤーでのトランザクションの発行部分です。タスクの更新では、現在のタスクの情報をデータベースから読み取り、ドメインオブジェクトを編集後データベースに更新処理をかけています。読み取りと書き込みが同時に起こる例です。ここは、tryBeginWritableTransaction関数で囲っておしまいです。

class TodoApplicationService(
    private val conn: DatabaseConn<Permission.Writable>,
    private val repository: TodoRepository
) {
// ...
    suspend fun updateTodo(command: TodoCommands.Update): Result<Unit, AppErrors> =
        conn.tryBeginWriteTransaction {
            binding {
                val existingTodo =
                    repository.getById(TodoId(command.id))
                        .toErrorIfNull { ApplicationServiceErrors.EntityNotFound(command.id) }
                        .bind()
                val toBeUpdated = UnvalidatedTodo(
                    id = existingTodo.id.value,
                    title = command.title ?: existingTodo.title.value,
                    description = command.description ?: existingTodo.description?.value,
                    due = command.due ?: existingTodo.due?.value?.toLocalDateTime(),
                    status = command.status ?: existingTodo.status.asString(),
                    createdAt = existingTodo.createdAt.toLocalDateTime(),
                    updatedAt = LocalDateTime.now()
                ).let { ValidatedTodo(it) }.bind()

                repository.update(toBeUpdated)
            }
        }
}

選ぶライブラリによってはこの辺りを真剣に考慮する必要があります。知る限りではJava系では@Transactionalのようなアノテーションをつければ済むものが多く、ドメインレイヤーにリポジトリを置いて終了です。一方で、GoやRustのライブラリでは、トランザクションがスコープではなく型として表現されるものがあり、トランザクション型がドメイン側に流出するのを許容するなどの何かしらの追加の対応が必要になる印象を持っています。

まとめると、

  • DIPする前提の場合
    1. インフラレイヤーで発行して大きい集約を許容する。
    2. アプリケーションレイヤーで発行してドメインレイヤーにリポジトリのインタフェースを置く。ドメインにインフラの情報が流れ込むのを許容する。
    3. アプリケーションレイヤーで発行して、アプリケーションレイヤーにリポジトリのインタフェースを置く。
  • DIPしない前提の場合
    1. インフラレイヤーにリポジトリの実装をおきつつ、トランザクションもインフラレイヤーで発行して大きい集約を許容する。
    2. インフラレイヤーにリポジトリの実装をおきつつ、トランザクション自体はアプリケーションレイヤーで発行する。

となりそうです。

ExposedのnewSuspendedTransaction周りの例外ハンドリングについて

ところでトランザクションの発行は、今のサンプルコードではブロッキング処理になっています。Exposedはノンブロッキング対応版のnewSuspendedTransactionという関数を用意しており、これを使うとsuspendで実装を通し切ることができます。が、coroutineの例外ハンドリングを上手に制御しきれず、うまく実装することができませんでした。というのも、変に例外をキャッチしてしまうと、newSuspendTransactionが内部で走らせるロールバックをうまく発火させられないからです。この辺りは悩んでいる人がいるようで、Issueとしても上がっていました

下記は、newSuspendTransactionを導入してみたものの、リポジトリ内で発生したエラーを上位レイヤーに上手に伝播できず、結果廃止した実装例です。

github.com

(本格的に調査しきれたらこの部分を更新していると思います。)

現状の実装では、一旦妥協策としてブロッキング側のtransactionを利用しつつ、それをwithContext(Dispatchers.IO)で逃しておくという実装にとどめています。この実装は確かに手軽に行えるのですが、実運用上では問題が起こるかもしれません。Dispatchers.IOのスレッド数を適切に制御する必要が出てくるためです。

まとめ

ここまで長々と書いてきましたが、実はまだテストに関する話を書いていません。つまり、道半ばというわけです。この手の情報は一度Zennの本にしてまとめるなどした方が、チームメイトにも伝えやすくなりますし、他の方の役にも立ちそうな気がしてきました。今年1年くらいかけて書いてみてもいいかもと思い始めています。

書いていたら思ったよりレイヤーの話が多くなってしまいました。物をどこに置くか議論は楽しくはあるんですが、物事があまり前に進んでいる感じはしないですね…。Ktorのような設計のライブラリを利用しているからこうなる説もあり、Ruby on Railsのようなレイヤーがはっきり決まったフレームワークを採用した方が、実は考えることも少なくて開発が早まるのでは…と思わなくもなかったです。このあたりの議論はROIが低そうなので、参照する本を一冊決めてそれにみんなで従うとかした方がいいのかなと思っています。

たかがToDoアプリでここまでやるかという話ではあるんですが、自分の頭の整理によかったかなと思います。これはあくまで事例のひとつかつ、私の好みを存分に反映しました。仕事で採用している言語やライブラリを使って一度自分ならどう設計するかを考えてみると、たとえばまったく新規のプロダクトを立ち上げることになったとしてもよりよい設計でスタートできますし、日々の業務にもフィードバックできます。RustとAxumで2021年にやってみたこともあるんですが、これを元に書籍を書くことになったりもしました。

「What if」を考えるのは常に楽しいですね。今後もプロダクトが変わるごとにやっていきたいです。そして、レイヤリングやモデリングなどの議論では、常に手を動かしてみてどうなるかを実験し続け、思考し、検証し続けたいものです。

*1:そもそもレスポンスを返すのにcall.respond関数などを使用しますが、これはUnit型を返す、つまり副作用を生んでいます。これがまず微妙です。

*2:この場合、よほどの事情がない限りScalaを選んだほうがいいと思いますが…。

*3:ところでcopyっていります?単にオブジェクトを作り直せばいいような気がしました。生成AIでコード補完できる昨今であれば、ほぼ手間に感じることもなさそう。

*4:正確を期すために言及しておくと、どのタイミングで値が確定するかはコルーチン機構ののスケジューラ次第となりそうです。

*5:ちなみに普通のsuspend関数はどう型として表現されるのか調べてみたのですが、Continuationという型で表現されているようです。おそらくですが、suspendはいわゆるシンタックスシュガーなのでしょう。

*6:この部分を切り出して、「asyncは並列処理用である」という解説をする記事をいくつか見ました。しかしそれは厳密には微妙に誤りだと思っていて、あくまでasync/awaitは並行処理というか非同期処理のためのツールだと考えています。なので、並列処理という言い方を意図的にしていません。

*7:この本ではヘキサゴナルアーキテクチャが採用されています。語やレイヤー定義の違いには注意が必要です。ただしこの本では、トランザクションの扱いは完全には答えを出しておらず、要議論だねで済ませています。書いてくれ〜〜〜

*8:suspendがついてないじゃないかと思われるかもしれませんが、後述するようにExposedを利用する際に調査中の事項があり、調査が完了してトランザクションの発行をノンブロッキングにできると、suspendを頭につけられるようになります。それを想定してアプリケーションレイヤーに置いています。

2024年読んで印象に残った本(技術書編)

2024年に読んで印象に残った本の技術書編です。去年はそんなに多くの冊数は読めていません。というか、技術書を執筆して出版したので、技術書そのものにお腹いっぱいだったのは大きいと思います。

本を書いたという話は下記です。

blog-dry.com

非技術書編を先に書いているので、よかったらこちらもどうぞ。

blog-dry.com

免責事項ですが、記憶を元に書いている箇所が含まれることがあります。また、書籍のリンクにはアフィリエイトコードが付与されているので、苦手な方はURLから外してご購入ください。

目次

ルールズ・オブ・プログラミング

Ghost of Tsushimaを作った会社の人が、こういう会社で扱われる大きめの規模のプログラムをいろいろ書いてきた経験を通じて、大事だったと思われる話を紹介してくれる一冊です。

今読み返しても「抽象化ないしは一般化は事例が最低でも3つ見つかるまではやるな」というのがまず目に入ってきます。先日Rustのような抽象力の高いプログラミング言語であったとしても、Goを書くかのように愚直にコードを書くべきという話で盛り上がっていたのをTLで見ました。私は常に愚直に書くべきとは思っていなくて、抽象化できる部分はもちろん抽象化してもいいのだけど、大半のケースで「結局これに対応してないじゃん」が発生するんですよね。事例が3つくらい見つかると、そろそろいい共通項が見つかり出すように思っていて、そういうタイミングが来るまでは愚直に書いておいて、来たら抽象化したらいいと思います。見つかる前からDRYを意識しすぎて抽象化しまくるのは大体いい結果に終わらないので、たしかにやめておいた方がいいなという経験則があります。

あとは、「ジュニアとジュニアのコードレビューは本当に意味ないからやめよう」でしょうか。コードレビューの話は私のいる大規模めのプロダクトでも、品質担保の一環としてよく議論に上がるんですが、まず最初にこれをチェックするようにしています。ジュニアというのは、要するに業界に入りたて〜人によっては3年目くらいまでのソフトウェアエンジニアのことを指しています。あるいは、プロジェクト入りたての人同士でもと言い換えてもいいかもしれません。要するに超若手同士/オンボーディング中の人同士でコードレビューをしても、たとえば業界経験が浅すぎるが故に深いフィードバックを得られず、結果彼らにとっても成長につながらないといったところでしょうか。そうならないように、必ず経験の長い人が入るようにした方がよいと思います。

感想は以前こちらに記しています。

blog-dry.com

Tidy First?

そういえば翻訳が出たようですね。めでたい!原著で読みましたが、いい本でした。翻訳版は持ってないので、「tidying」がどう訳されているかはちょっと知らないのでその点にご注意ください。

Tidyingは、要するにリファクタリングには至らない規模の、ちょっとしたコードの整理のことを指しています。リファクタリングの部分集合のようなものでしょうか。たとえば、関数の順序を読みやすく入れ替えたり、概念的に違う箇所にあるものを適切な位置に戻したりするなどがこれに該当します。

リファクタリングはどちらかというと大規模になりがちで、機能開発を止めがちです。なので、リファクタリングそれ自体コストのかかる活動だと認識されがちかもしれません。これ自体コストのかかる活動だということになると、ついつい後回しにしてしまいがちでしょう。ただ、リファクタリングが本来になっていたようなコード整理の活動をおろそかにすると、将来どこかでその借金を返済しなければならないときがきます。

著者はそこに問題意識を感じており、Tidyingという小さめの改善活動を日常の開発業務の中にとりいれていく必要性を強調します。本書の2章ではどのようにそうした活動を運営していけばよいかについて触れられており、参考になります。たとえば通常の機能開発のPRとTidyingのPRを混ぜないなどです。

最後の章の経済分析は、NPVなどを用いて「今それをやる理由」を説明しています。経済分析の中身自体は金融工学の初歩的な知識を多少要求するようには思いますが、要するにこうしたリファクタリングというかTidyingの活動には経済性はついて回るもので、経済性も説明できるのだ、だからやらない理由はないはずだという基礎づけをしているのかなと思っています。

感想は以前こちらに記しています。

blog-dry.com

Domain Modeling Made Functional

やっと読めました。通読したので、ぼちぼちRustやKotlinのような言語でどう実践できるかを考えています。こちらも翻訳が出たようですね!そして、やはり翻訳版を持っていないので、どう訳されているかはちょっと知らない点にご注意ください。

本書の主題は、関数型プログラミングの手法をDDDのような設計手法に持ち込んでいくとどういうデザインになるかを探求していると理解しています。逆にいうと、F#のようなゴリゴリの関数型プログラミング言語でDDDを実践するとどうなるかを考えているともいうでしょうか。

とりあえずよかったポイントとしては、型付けをしっかりやりながらコードを書くという議論がなされている点でした。古めのJavaのような言語を使っていると型付けがどうしても緩いというか、オブジェクトの中身の状態を変化させながらコードを書く関係で、そのオブジェクトの状態が今何なのかは、コードを実際に動かしてデバッグしないとわからないとなりがちだと思います。こうした問題は、そもそも状態に型付けをすれば回避できることが多いです。この議論は多くの現場で実践しやすいように思っていて、ぜひ私の所属するプロダクト(Kotlin製)でもとりいれていきたいかなと思います。というか、すでにとりいれています。

次に、Railway-Oriented Programmingという手法についてです。RustやScalaを使っているとこの話は当たり前のように感じてしまうのですが、他の多くのプログラミング言語では、そもそも例外が使われていて、例外を送出するスタイルをとっていることが多いです。Kotlinもご多分にもれずです。例外創出は単純に型付けとして表現されないので、その分自分たちで組んだ型の体系に抜け穴ができることになります。型つけや論理を重視する関数型プログラミングスタイルではこれは避けたいところでしょうか。というか単純に純粋性がなくなるのでダメですね。

ところで、このRailway-Oriented Programmingは注目を集めているようです。さまざまな言語で実践してみている例を目にします。が、本当に導入すべきかは検討の余地ありだと思っています。私の個人的な意見としては、Railway-Oriented Programmingは最終的にはflatMapを多用しなければならなくなるわけですが、このflatMapに対する特別な言語処理系側からの支援機構がない限りはただただコードを読みにくくするか、型パズルを辛くするだけではと思っています。Rustなら?Scalaならfor-yieldが支援機構に該当します。これがないプログラミング言語では、むしろおとなしく例外を使った方がいいのでは…と思わなくもないです。

関数型プログラミング言語をそれなりに使ったことがある方であれば、本書の内容それ自体はあまり大きな驚きはない印象です。すでに実践しているよと思う話が大半でした。

私はどちらかというと、Scalaで書かれたこちらの方がためになったかなと思いました。いろんなモナドを駆使してDDDを実践する一冊です。たしか中身はScalazだったと思うんですが、Catsにも同等の機能はたくさんあるので、Catsで再実装してみるとおもしろいです。この本のおかげで、モナド慣れ(?)できました。

naoyaさんのこのスライドも参考になりました。

TypeScript 関数型スタイルでバックエンド開発のリアル - Speaker Deck

私自身も、Scalaで書き直してみています。TypeScriptの例ばかり見て、さすがにそこはScalaだろうと思ったのでScalaで書いてみています。Scalaならめちゃくちゃ綺麗に書けますね!Haskellの練習にも良さそうなので、いつかやってみたいです。

github.com

大規模データセットのためのアルゴリズムとデータ構造

広告配信にいたころは日夜超大規模なデータとの戦いをしていたので、その頃に欲しかった一冊だったなと思います。もうちょっと早く知っていれば…と思わなくもないですが、よい読書体験でした。ただこの分野はまだ勉強中で、上の数冊のように考えたことをつらつら書けるほど理解もしていないので簡潔に済ませておきたいと思います。

古典的なアルゴリズムやデータ構造はそれなりに理解しているし使いこなして日常業務にあたっているけれど、日々の開発で「実はここはもっと効率のいい方法がとれるんじゃないか」と思うことがあると思います。広告配信の場合、キャッシュを多用してなんとかレスポンス時間を高速に保つという泥臭い努力が行われるわけですが、メモリにすべての広告情報が乗り切ることはまずありません(乗り切ってしまうようなサービスは案件が少なすぎるので早晩潰れます)。そういうとき、たとえば本書でも紹介のあるブルームフィルターを知っていたりすると、別の効率良い手段をとれるといった具合です。

私も完全に初学者なので勘所は詳しくはわかりませんが、本書は「実はここはもっと効率のいい方法がとれるんじゃないか」を紹介してくれている一冊だと思っています。なかでの議論も数学的にきちんと説明されていて、入門書として適切らしいのはもちろんのこと、一介のソフトウェアエンジニアにとっては、採用できる手段の引き出しを増やす意味で読んでおくといいんじゃないかと思いました。私も純粋な興味が半分と、そういう動機が半分くらいで読みました。

コード×AIーソフトウェア開発者のための生成AI実践入門

生成AI関連の今年読んだ本の中では最もよかった一冊でした。私自身はそもそも巷を騒がせているような「何かを作れているAI」に対する関心はそこまでないです。というのも、自分で手を動かせば作れてしまうからです。ただ一方で、「自分で手を動かして作るスピードを高める方のAIの使い方」には興味があります。要するに、コードを書いている最中のAI活用の文脈です。本書はその部分で活用できるのではないかと思って手に取りました。

書籍の中には、たとえばプロンプトをどう書いたら効率がよいかや、アカデミックな場所で議論されているプロンプトの技法などが紹介されていて、生成AI素人の私も大変ためになりました。普段はGitHub Copilotをそれなりに使って実装を行っているわけですが、そうした人であってもたとえばコードのピン留めなど役にたつテクニックが記載されています。また、生成AIにもいくつか種別があって、目的や用途ごとにツールを使い分けると良いという話は大変ためになりました。GitHub Copilotと、GitHub Copilot Chatをこれにより使い分ける日々が始まりました。

それよりさらに興味深く思ったのが、AIが意図を把握してくれやすいコードの書き方の部分でした。何か特別なことがあるのかと思いながら読み始めたのですが、結局紹介されている技法はすなわち人間にとっても読みやすいコードを書くためによく使われるテクニックでした。一見するとこのことは何もおもしろみがないように感じられてしまうかもしれません。しかし見方を変えれば、結局AIが意図を把握しやすいコードというのはそもそも人間が意図を把握しやすいコードなのだ、ということです。つまり普段の開発から、チームメイトや未来の自分に意図の伝わるようなコードを書いたり、コメントを残したり、ドキュメンテーションを行ったりすることが、そのまま生成AIを上手に使うことにつながるのだと気づきました。

ソフトウェアエンジニアの仕事がAIによってなくなるかというと、現時点のレベルのAIだったらなくならないと思っていますね。現時点の中途半端なAIであれば、2024年読んで印象に残った本(非技術書編) - Don't Repeat Yourselfでも取り上げた『技術革新と不平等の1000年史』にも書かれていた通りで、劇的には世の中や社会を変えてはいかないと思っています。

なぜかというと、そもそも出力が正しくないケースがまま多いからです。少し賢くなったコード補完程度の役割であれば十分こなせると言えばこなせますし、またAIでゼロから100まで全部作り上げたとしたらおそらくまあまあ動くものを作れそうな気はしています。しかし、ソフトウェアエンジニアリングというのはすでにあるソフトウェアの保守運用や改良改修がメインの作業のはずです。このソフトウェア産業を成立させるためには既存ソフトウェアに対する高品質な改修は欠かせないわけですが、AIにはそれは今のところは無理でしょう。

たとえば自身の文脈の外にある事象が多分に含まれるようなコードベースに関する、既存の機能の改修に類するような問いかけをすると、ソースないしは知識不足からなのか割と出鱈目な答えが返ってきます。[*1]既存の文脈の把握は人間の場合だと現場の人に聞くという作業が経由されるわけですが、AIについていえば今のところプロンプトの入力者の知識を越え出ることはありません。これは割と現場作業では致命的で、要するに作業者の作業がスケールし得ない(入力者の主観を越えなそうな)ことを意味すると思います。加えてこうした誤認の度合いは、対象の傾向にももちろんよるものの、おそらくですが同程度の知識を得た人間よりもまだよくない感じがします。このギャップが埋まらないとソフトウェアエンジニアの代替はまだちょっと厳しいですね。AIエージェントというものがこれからこうした問題を解消してくれそうですか?であれば、失職するかもしれません。

しかし一方で、生成AIの活用は社会インフラに近い存在になってしまったのもあり、いつまでも小馬鹿にして距離を保ち続けるのも違うとは思っています。インフラとなった以上はそこに乗っかって新しい機会や生産性向上を享受する必要があります。なので、来年以降もいいツールが出れば使い続けたいとは思っています。仕事で何か施策を行いたいとなったときにも、AIを活用して自動化できないかは常々考え続けたいものです。

モデル検査器をつくる〜Goで実装して学ぶ形式手法〜

techbookfest.org

最後はこれです。技術書典でお見かけしたので、興味を持って買ってコードを写経しました。形式手法については業務でいかしてみたいと常々思っていて、いかすためにはまずそもそも何ができるかを知っていかないとと思っています。

内容の紹介は著者の方のブログへのリンクを貼っておきます。

ccvanishing.hateblo.jp

余談ですが、巻末に次の学習のための参考文献がいくつか示されているとさらに嬉しかったです。独学しようにも、書籍のガイドがいかんせん少ない分野な気がしているためです。

まとめ

とりあえず年末年始読み切ろうと思っている本は下記です。最近はアーキテクトという仕事に半分くらい足を突っ込んでいるのですが、意思決定をする場面が非常に多いです。トレードオフなどを検討しながらいろんな人の意見をまとめる必要があるわけですが、結局何を大事にしたらいいんだっけ?をよく忘れてしまいます。局所最適解を導かないようにしていきたいものです。

*1:もちろんプロンプトエンジニアリングをはじめとする訊き方の工夫や学習させるデータの質を改善するなどすれば、相当数改善するかもしれません。しかしその手間にかかるコストが、人に聞いて解決する場合と比べて割に合わないように思います。割に合わなさもまた、AIの課題点だとは思います。

2024年読んで印象に残った本(非技術書編)

2024年は終わってしまいましたが、2024年読んで良かった本を紹介したいと思います。今回は技術書でない本を紹介します。

2023年くらいからこちらのブログでやっています。

blog-dry.com

免責事項ですが、完全に読了したものではなく読みかけの段階でも「これは…」という本も紹介しています。内容については記憶を頼りに書いているので、一部解釈誤りや記憶違いなどを含む可能性があります。アフィリエイトが入ってるのであらかじめご了承ください。

目次

技術革新と不平等の1000年史

『国家はなぜ衰退するのか』を書いたアセモグルらによる新しい本です。この本を読み終わったくらいにアセモグルやロビンソンがノーベル経済学賞を受賞し、一躍話題の一冊となりました。

2024年はというと、生成AIが技術的な話題の多くを席巻していたように思います。年の初めにはこれまでの機械学習や深層学習の成果物と比べると一段レベルの違うものに仕上がっているように感じていました。そして一般に広く普及し、仕事をしていてどの職種からも生成AIのワードを聞く時代となりました。いわゆる技術革新が起きたタイミングとして、2024年は歴史に残っているだろうと思います。

こうしためざましい技術革新が進む一方で、技術革新の結果生まれたであろう生産性向上や「繁栄の共有」は幅広く行き渡っていると言えるでしょうか?答えは今のところはおそらくノーです。少数の巨大なテクノロジー企業が新たに生まれた富を寡占するような事態になっており、富はそこに集中しています。下手をすればひとつの国規模の経済圏を持っているとも言えるかもしれません。そしてその富は、経済圏にいない人々には行き渡らない状態になっているでしょう。

技術はこれまでの歴史において、きちんと人類に富をもたらしてきたのか?という疑問に対して、実証的に答えていく一冊です。著者らは、まず技術に対する下記のような楽観的な表明(本書では「生産性のバンドワゴン」と呼ばれています)に対して疑義を表明します。

企業は生産性が向上すると、生産量を増やしたいと考える。それにはより多くの労働者が必要なので、雇用の確保に奔走することになる。多くの企業が同時に同じことをしようとして、賃金をいっせいに引き上げるのだ。

技術は、それ自体で富を社会全体に行き渡らせることは決してないといいます。技術革新の恩恵に多くの人々があずかれるかどうかは、技術のビジョンによるといいます。要するに技術がどう使われるか、その結果得られる恩恵をどう分配するかをどう設計していくかに委ねられているということです。ただしその設計は、政策などの意思決定に関われる「選択をできる人」らの選択に完全によります。翻すと、技術革新や技術それ自体が手放しでいい結果を労働者や社会にもたらすことはないのです。

この議論について、歴史的な技術革新のタイミングを振り返りながら検証を進めていきます。とりあげられるのは、農業革命、ピラミッド建築、科学革命、産業革命、フォード、コンピュータ革命などです。このうち条件付きでうまくいったのは産業革命、フォードのみで、他は散々な結果になっています。注目すべきなのはコンピュータ革命も、決して技術革新の恩恵が広く行き渡ってはいないということです。私にとって重要だったのは、つまり現代は、恩恵を受けられていない時代であるという認識を持つ必要があるのだということです。

そして注目すべきは最後のAIオートメーション革命に対する著者らの警鐘でしょう。これまでの歴史上大半がうまくいっていないように、やはり同様にAIオートメーションもその結果得られた富が広く分配されているとは言い難い状況にあるようです。AIについて楽観的な議論を始める前に、一度1000年分の技術の歴史を確認し、現実を知るのもよいかもしれません。

論理的思考とは何か

論理的思考というと、コンサルの人たちが書くああいうロジックツリーをはじめとした思考方法や文章の書き方を指していると思いがちです。そして、あれこそが「論理的」なのであり、たとえば日本の読書感想文のような、結論のよくわからないお気持ち表明大会は「論理的ではない」、およそ教育的ではない[*1]何かと切り捨ててしまいがちではないでしょうか。実際私も読了後でさえ半分くらいはそう思っていますが、それは置いておいて、論理つまり人の説得のフォーマットには、文化圏によってさまざまな手法があるのだと視点を相対化できる一冊です。

筆者によれば、論理的思考は4つの様式が観測可能で、文化圏によってそれぞれ独自の体系をもっていると説明します。代表的な4つは下記です。

  1. アメリカ: いわゆるロジカルシンキングとしてビジネスシーンなどで習うスタイル。
  2. フランス: 正反合を大事にしながら議論を展開するスタイル。
  3. イラン: 真理かどうかを詩などを援用しながら、ある種宗教的に定められた結論に論証しながら向かうスタイル。
  4. 日本: お気持ち表明大会。というか、筆者の体験や経験を共有し共感を誘うスタイル。

ところで筆者がわざわざ類型を4つあげていますが、この目的は思考方式の相対化です。これらの間に優劣や良し悪しがあるのではなく、差異を示しているのだということを念頭に置く必要があります。「論理的思考」というと、私たちのようにビジネスシーンに触れる人からするとついついアメリカ式のそれを思い浮かべてしまいがちです。しかし、世界にはその土地の価値観に基づいたさまざまな思考体系があると念頭に置いておくだけでも、異文化の相手の理解に一歩踏み出せるようになるかもしれません。

私がおもしろかったと感じたのはとくにフランスの部分でした。学生のころ、フランスの現代思想哲学書を読まなければならなかったのですが、中で展開されている議論はおよそわかりやすく直線的に仕上がっているとは限りませんでした。いろんな文学や音楽、映画などからの引用はもちろん、そもそも議論それ自体が入り組んでいるのです。英米系の哲学書は対称的に、議論の流れをとても追いやすく直線的な流れだったように思います。

本書を読んで、それがフランスの議論のスタイルなのだというのを知りました。本書ではフランス式の思考体系は「政治領域」として扱われていますが、政治ではさまざまなバックグラウンドをもつ人々の間に立って利害調整しなければなりません。そうした議論は、直線的かつ構造的にわかりやすいものになっているとは限りません。また、直線的な議論をすると、思わぬ考慮漏れを起こすかもしれません。正反合のスタイルにならって緻密に議論を練り上げることで、より多くの物事を考慮の中に入れられるという良さをもっていると言えるでしょう。

フランスの思考法が個人的にはおもしろかったのもあり、下記の本も読んでみようかと思っています。

近代美学入門

もともと美術館が好きで、美術検定なども実はもっていたりするくらいなのですが、子どもが生まれてから主には子どもが急に騒いでしまうことを遠慮して行かなくなってしまいました。行かない(行けない)代わりに、美術に関する書籍を買っては読んでしまった1年だったなと思いました。

去年だと、ゴンブリッチのこの本が手頃な価格で売り出されていたので、思わず買ってしまいました。なお、我が家の大きめの文鎮になっています。時間見つけてコツコツ読みたい。

ところで最近調べて知ったのですが、国立美術館などでは子ども向けのイベントを定期的に催しているようです。平日に時たま検診が入って行く必要があるので仕事を休むことがあるのですが、そういうタイミングで連れて行ってもいいなと思っています。お子もそろそろこちらのお願いを聞いてくれるようになってきましたし、2025年はぼちぼち復活させていきたいですね。

話はそれましたが、美学というのは美しさに関する哲学の一分野です。美しいとはどういうことかを論じている学術領域です。私も美学という単語自体は知っていました。学生のころにこっそりカントの『判断力批判』を読んで、わけがわからなくて挫折したことがあります。純粋理性と実践理性はそんなことなかったのにと思うんですが、今思えば美学に関する歴史的な経緯の知識や前提知識が完全に脱落してたのだなと思います。

この本で一貫して述べられていたように思うのは、我々が今当たり前のように「美しい」とか「これが芸術だ」と思うその行為それ自体が、近代以降に「発明」されたものなのだ、ということでしょう。そもそも芸術という概念自体は、近代以前の時代にはありませんでした。というのも、芸術品はギルドやパトロンの依頼によって作られるものだったからです。また、「美しい」という言葉それ自体も、近代以前は数学的な美しさのようなものを指していました。なので、普遍的な「美しさ」を追求する研究がずっと行われていました。

近代に入って、ギルドからの依頼制が解体されて美術アカデミーができたり、また一般の人も芸術を楽しむようになるなどの変化が起こりました。思想上でも、美しさというのは客観的なものばかりではなく、もっと「美しいと思う」感情のような部分があるはずだという議論がなされるようになりました。美の主観化です。

加えて、私が別の本を読んでいて詳しく知りたいと思っていて、本書を読む動機にもなった「崇高」という概念の解説も入門的ながらも、十分に行われていると思います。崇高というのはやはり感情で、美の主観化発生以降、「美」それ自体から分離する形で生まれたものだったようです。大自然を見た時に感じるような畏怖を含むアレです。

崇高自体は千葉雅也さんの『センスの哲学』を読んで興味を持ちました。そういえば、これも2024年に読んだのだったかな。

カントの判断力批判をちょっとは読めそうな気がしてきたので、次はチャレンジしてみたいなと思っています。美学、おもしろい。

エビデンスを嫌う人たち

非科学的な言説や陰謀論を信じている人たち(という言い方が、すでにそういった人々との線引きをしていることに他なりませんが…)とどう向き合っていけばよいかを哲学的な目線から論じた一冊です。結構話題になっていた本だと思います。

結論から行くと、まず仮に自分の友人知人が非科学的な言説や陰謀論に染まった場合、放置すると余計に悪化するので放置しないようにしましょう。次に、対話の仕方があるのでそれを学びましょう。具体的には、書籍で紹介される5つの誤解を丁寧に解消していきましょう。これを行う際には、まず感情的にならないことや、相手に敬意をもって接すること、相手がそう考えるに至った経緯をきちんとヒアリングすること、その上で、相手のそこに至った経緯は理解したことを伝えつつも、はっきりと別の立場を伝えるといった流れにすると良いだろうと、著者は言います。

そしてこれらに加えて、誰からそれを言われたかも意見を変えるファクターになりえます。相手との信頼関係がない状態で、相手の信念を変えさせるような話をしたとしても、無用な反発を抱いて終わるだけだという話です。何を当たり前のことを…と言いたくなりますが、この当たり前が一切成り立っていないように見える空間があります。SNSです。SNSでは日夜、こうした著者の提案するパターンとは逆の行動がとられ続けており、余計に「あちら側」と「こちら側」の分断を生んでいると思います。

ところで私は著者をちょっと狂っているなと思ったのですが、なんとフラットアースの集会や非科学的な言説を信じる友人と実際に対話をしに出向いています。狂ってるなというか極めて実践的な哲学者だな、と評するべきかもしれません。私は身の危険を感じそうなのと、普通に感情的に言い返してしまいそうなのでちょっとできませんが。こうした人々との対話も具に記述されており、味わい深い一冊に仕上がっていると思います。

こうした「あちら」と「こちら」の対立構造はいたるところで発生しており、その根元にあるのは、どちらかサイドからの軽蔑のまなざしなのかもしれません。この辺りの話は、マイケル・サンデルの書籍にも書いてありましたね。

乳幼児は世界をどう理解しているのか

文字通り、乳幼児の世界の認知に関する学術的な議論が詰め込まれた本です。この手の話はテレビなどでは謎のオカルト的信仰も相まって、いわゆる非科学的な話がまぜこぜになって紹介されていますが、そうしたものに裏付けが一切ないことなどが紹介されています。否定される代表的なおとぎ話は、胎児は子宮の中にいるときから記憶があるという類の話です。

子育てをしていると、子どもがどうやって認知能力を獲得していくのかを体系的に説明した本が少ないことに気づきます。子どもに関する話をちょっと調べたりすると、世の中にはいい加減というか科学的に十分な裏付けのない俗説がたくさん流れていることに気づきます。子育てに関わる多くの人が、そうした体系的な知識を学ぶことなく仕事に就いているのではないか?という気持ちにさせられるくらいです。

私が実際役に立ったのはたとえば、

  • 赤ちゃんの成長においてはいわゆる三項関係の成立が非常に重要だが、三項関係の発達は歩き始めと大きな相関がある。というか、歩くことそれ自体が赤ちゃんの認知能力を飛躍的に向上させる。身体的な成長が認知能力を底上げするという議論はおもしろく感じる。
  • 幼児期健忘と呼ばれるものがある。大人になって思い出せるのは基本、4歳以降の記憶のみで、それより前の記憶は忘れられてしまう。おもしろいのが、3、4歳の子どもでも、2歳より前のことは忘れているらしい。
  • 子どもが記憶を頼りに何か話をするときは、とても大人に誘導されやすいことに注意がいる。なので、基本的に誘導されていないか注意しながら話を聞かないといけない。
  • 親の会話では、子どもがきちんと自分の説明をより掘っていけるように質問してあげるのが大事。精緻型と呼ばれる会話スタイル。単に子どもの言ったことを繰り返す場合、子どもの言語面の成長は、精緻型の会話をする親と比較すると遅い。
  • 「いないいないばあ」がおもしろいのは、大人の顔が消えた後に「現れるだろう」と予想を立て、その予想が当たるのがおもしろいらしい。(なので、いないいないばあで笑えるということは、物事の流れの予測が立っていると言えるのかもしれない。)
  • 「風邪を引いたのは大人の言いつけを守らなかったからだ」(みたいな、要は原因が自身の道徳的によくない行いにあると考えること)という考えは、子どもだけかと思いきや大人にも残り続けている。

などでしょうか。

とくに、認知能力が体の発達に伴って向上する点は、私にとってはおもしろい点でした。というのも、従来の哲学的な議論では、いわゆる認識は精神(心)でのみ取り扱われ、身体側が干渉することがないかのように語られることが多かったためです。私は身体性と認識は切っても切り離せない関係にあると思っていましたし、その点について従来の哲学はかなり改善できるポイントがあると思っていました。個々人で身体的特徴が異なる分、身体を通じて認識される世界は当然個々人によって異なるはずだと思っていたからです。それだけでなく、認識の成長は身体に駆り立てられるのだという今回知った議論は、この考えを前に押し進めるものになりそうな気がしました。

昨年紹介したかもしれませんが、下記も関連してよかった本でした。

他者と沈黙

まだ読みかけです。本書を読む前、ウィトゲンシュタインというと他者性が1ミリもない感じの哲学者では…と私は半分くらい思っていました。実際、『論理哲学論考』は「独我論的では」という批判を受けていたようですし、ウィトゲンシュタインもそのことに気づいて、のちに別の論考を発表して自身のアイディアを少し調整しています。

サブタイトルにある「ウィトゲンシュタインの哲学からケアの哲学へ」という流れに興味を持ちました。ケアの哲学というのは最近よく見る話なように思っています。本書を読んだ限りの入門的な知識によれば、ケアというのは看護や介護のことそのものを指しているのではなく、「他者が成長すること、自己実現することを助ける」ことを指しているのだそうです。これは従来においては介護や看護の現場に閉じていたといえば閉じていたようなのですが、この概念自体を取り出して生活知にできないかという取り組みが至るところで試みられているように思います。ただ、この概念自体は確かにあるものの、内実は結構曖昧で漠然としているし、場合によっては「愛」「共感」などの規範化された自然的感情によって覆い隠されてしまい、実際何であったかの把握が難しくなっていることも多いでしょう。要はさまざまな方向からの定義づけが必要な段階なのかな?と私は本書を読みつつ理解しました。

私自身はウィトゲンシュタインはまったく詳しくないので概要的な部分しか正直知らないのですが、著者も冒頭に書いている通り、やはりウィトゲンシュタインとケアを結びつけて論じることは珍しいことのようです。ただ、ウィトゲンシュタインが後期に提唱した言語ゲームという概念が、ケアという概念の輪郭をはっきりさせるための足掛かりとなるのではないかと著者は考えているようです。

ウィトゲンシュタイン関連では、2024年は下記の本も読んでいました。完璧主義者で近寄りがたい人かと思っていましたが、意外に友だちを大事にしているなど普通の人でした。カントみたいですね。

私もまだ半分くらいしか読めておらず、本書の内容は正直なところ若干消化不良ではあります。が、着眼点のおもしろい本だなと思ったので紹介しておきます。

モヤモヤする正義

まだ読みかけです。本屋で見かけて少し読んだときに、SNSやその他の言説を日々見ながらもやもやする話の核心を的確に言語化し、それに対して哲学や倫理学の概念を使ってアプローチしていく流れがおもしろく購入しました。コレクトネスとどう対峙していけばよいかを悩んでいる人におすすめできるかもしれません。たとえば、表現の自由というけれどどこまで許容されるんだっけ?とか、キャンセルカルチャーになんとなくモヤモヤを感じるけど、果たして何を認めて何を認めたらいけないんだっけ?とか、XXXという集団は特権をもっていると言われるけど、そもそも特権をもっていると言われる謂れはない。どう反論していけば?といった疑問について、何が問題でどういう背景があるかなどを述べてくれます。

哲学の類の本なので、著者が解決策を「これだ!」と提示する類の本ではないです。というより、多くの人々はこうした問題に感じる「モヤモヤ」について、なんとなく漠然と違うと思っているが、何が違うのか具体的にはわからない、どこから手をつけていったらいいかもわからない、という状態にあるように思います。こうした漠然とした課題について、どこから切っていけばよいかを言語化してくれている類のものだと考えるとよいと思いました。さらにいうと、その切り口を哲学や倫理学というレンズを通じて眺め、問題の解像度を上げていっているという構成になっているのではないかと思っています。

まとめ

読書は常に時間の捻出との勝負です。2024年もあまり時間を捻出できずに負けました。2025年こそはと思っていますが、多分全部読み通せる本の方が少ないでしょうね。諦めています。

とりあえず積んでる年末年始の本は下記です。

庭の話は、とりあえず『中動態の世界』の続きの議論っぽいので買っちゃいました。庭という比喩がおもしろそう。

仮説とか問いとかそういう方向の研究における問いの立て方的な話が最近仕事でも大事そうだなあと思い始めていて、関連する本を何冊か読み漁っています。たとえば下記です。

*1:啓蒙された市民には相応しくない何か、の意。

Ghosttyを使う

日常的にはWezTermを使用しているのですが、Ghosttyがリリースされたのでセットアップしてみました。という記録です。

Ghostty

GhosttyはTerraformやVagrant、Packerなどを提供するHashiCorpという会社を作ったMitchell Hashimoto氏によって開発が進められるターミナルエミュレータです。Zig製です。

ghostty.org

興味深いポイントは、Zero Configuration Philosophyを掲げている点でしょうか。要するに設定ファイルをガチャガチャ書かずとも、必要な機能が揃っていて起動さえすればあとはいい感じに利用できるというものです。設定をせずにとりあえず試してみて欲しいという趣旨の話がドキュメントを見ると記載されています。

実際のところはこれからお見せしますが、設定ファイルの行数は本当に減りました。WezTermとの比較をすると、WezTermはLuaで記述する関係で行数がどうしても膨らみがちというのはあります。しかしやはり、(私にとって)デフォルトで設定されている項目がそれなりに心地よく、変更の必要を感じなかったのが大きかったかと思います。

当初はmacOSが第一ターゲットとして開発されていたような印象を持っています。それもあってかMacのウィンドウのエッジ部分のディティールがちゃんと反映されているなど、細かいデザインはGhosttyの方がよくできていると思いました。

設定してみる

Zero Configurationを掲げているターミナルエミュレータにさっそく設定を加えていくのもどうかと思いましたが、テーマやパディング周りは自分好みに変えたくなったのでそこだけ調整しました。

あらかじめお断りですが、普段からtmuxを使っている上に引き続きtmuxを使用している関係で、ペーンの分割など細かい部分はまだ手出ししていません。一旦外観の設定のみです。

WezTermとまったく似たような設定をしても面白みがないとは思うんですが、WezTerm側とほとんど同じ外観になるように設定してみます。要するに、

  • フォントはJetBrains Mono Nerd Fontを採用する。
  • テーマはCatppuccin Mochaを採用する。
  • 透明度は0.85くらい。
  • 背景はちょっとぼかす。

あとはせっかくなので、下記の設定も一部含んでおきます。

  • タイトルバーは出しておく。けど、透過させる。
  • ウィンドウのパディングを好みに調整する。

完成後のイメージとしては下記のようになります。

設定完了後の外観

完成した設定は下記です。短い。

theme = catppuccin-mocha
window-padding-x = 20
window-padding-y = 5
window-padding-balance = true
window-theme = ghostty
background-opacity = 0.85
background-blur-radius = 20
macos-titlebar-style = transparent

まずビルトインの設定がちょうど私の利用状況にフィットして、設定がまずまず削れています。フォントのデフォルトはJetBrains Monoである関係で、まったく指定を変更する必要がありませんでした。NerdFontも入ってるようで最初から動いてくれます。また、テーマもいろいろプリインストールされており、自分でテーマファイルを作って設定してという手間が省かれました。

プリインストールされているフォントは下記のコマンドで確認でき、

$ ghostty +list-fonts

プリインストールされているテーマは下記のコマンドで確認できます。

$ ghostty +list-themes

テーマの一覧の閲覧はすごくて、とても画面がわかりやすいですね。

テーマの選択画面

参考程度に、ほぼ同等のことをやるためのWezTerm側の設定です。こちらも長くはないはずですが、微妙に色の部分でバグってる箇所があって、それを手直しするなどしている形跡が見受けられます。今だともう直ってるのかな?

local wezterm = require("wezterm")

-- 略

local config = {}

if wezterm.config_builder then
    config = wezterm.config_builder()
end

config.color_scheme = "Catppuccin Mocha"
-- To reset the colour scheme since Catppuccin Mocha sets too bright orange to indexed 16.
-- This makes me difficult to see operations, for instance, renaming in Neovim.
config.colors = {
    indexed = { [16] = "#1e1e1e" },
}

config.window_background_opacity = 0.8
config.macos_window_background_blur = 20
config.window_decorations = "RESIZE"
config.hide_tab_bar_if_only_one_tab = true

config.font = wezterm.font_with_fallback({
    { family = "JetBrainsMono NF" },
    { family = "JetBrainsMono NerdFont" },
    { family = "JetBrainsMono" },
    { family = "YuGothic" },
})
config.font_size = 13.0

return config

速さ

WezTermを使っている時と体感あまり変わらない気がしました。いろいろ検証してみた限りだと、WezTermの方が少しだけ速いようには見えました。Discordを見ていると、リソースの使われ方はWezTermより優れていると言っている人もいました。詳しい検証はこれから上がってくると思うので、どっちがどうなんだろうなというのは気になりポイントです。

知人のkurochanもTerminal.appとの比較をあげていました。これと比較すると少しGhosttyの方が描画が安定してるかもしれません。

日本語入力

日本語入力の方ですが、Google日本語入力を利用しつつしばらくNeovimで開発するなどしてみたもののとくに問題を感じていません。フォントもちゃんと日本語のものが使われているようで、字体も日本語です。よくサポートされていると言って差し支えなさそうです。と思ったんですが、仕事用のPCは大丈夫で自宅用のでは変なフォントが使われている感じがしています。macOSのバージョンの差?で入っているフォントに差があるとかなんですかね?原因調査中。

WezTermとの比較

WezTermから移行したい理由はあんまりないですね。細かいUIの綺麗さはGhosttyのほうが上には感じますが、提供されている機能(というかそもそもtmuxでほとんど済ませているのであまり関係ない)や速度なんかもほとんどWezTermを使っていた頃と差を感じないです。WezTermはカスタマイズできる項目が多いので、それらをカリカリにカスタマイズして使っている方には多分物足りないでしょう。あるいは、Warpのような高機能めのツールを使っている方からすると、あんまり移行理由はないかもですね。私は細かいUIの綺麗さが気になるタイプなので、Ghosttyを試してみています。WezTermもそこまで激しくカスタマイズしてなかった、というのもあります。

まとめ

新しいツールを試すときって本当に楽しいですよね☺️ よさそうなのでしばらく使ってみます。

tokioから発表された新しいORM「toasty」を触ってみる

toastyは先日tokioから発表されたORMです。

tokio.rs

このORMは現状開発段階のもので、まだ実用に耐えうる段階にはないとGitHubには書かれています。というか、crates.ioにはダミー用関数が用意されているだけで、プロジェクトの依存に追加したとしてもまだ何もできません。現時点で対応しているのはsqliteとDynamoDBのようで、他のデータベースないしはCassandraなどには今後対応予定とのことです。async対応しています。また、SQLとNoSQL対応しているとなると、両者を抽象してくれるなにかかと思われるかもしれませんが、両者に対する操作を抽象してくれるわけではありません。

toastyの特徴

特徴としては、toastyというファイルにスキーマ定義を書き、toasty cliを実行すると、専用のRustコードが生成されるという点でしょうか。そして、生成されたコードにあるメソッドを呼び出し、データベースに対する操作を行うという流れになります。作者の方もRedditで言及していますがPrismaが念頭にあるようです。

toastyの記法は専用に開発されたもののようで、今のところone-to-manyをはじめとするORMらしい記述をexamplesで見ることができます。今後どんどん機能が増えていくでしょうね。

toastyファイルに書かれた内容を元に、Rustのコードが生成されます。このコードは新規のRustファイルが複数生成される形式になっています。つまり、アプリケーション本体側には手続き的マクロを使用することなく、toastyに書かれた情報を元にcliを経由してコードが生成されるということです[*1]

toastyは、今のところは手続き的マクロを使用しない方針を採っているというのが大きな特徴になると思っています。そして、この方式を採用するメリットは次のものが考えられます:

  • コンパイルが遅くなりにくい: アプリケーションのコンパイルとORM向けのコード生成のコンパイルが切り離されるようになります。その分だけコンパイル時間を外出しできることになり、短縮されます。それ以外にも、内部実装を軽く読んだ限りでは、ORMの機能を呼び出す側においてはトレイトを必要とせず、単に構造体に紐づくメソッドを呼び出すだけでよいようです。これは単純に使う側の使いやすさに直結するのと、コード生成量を減らせるのでコンパイル時間を悪化させずに済むように思われます。
  • 生成されたコードを簡単に読める、つまりGitHub上などでレビュー可能な状態に置かれる: 手続き的マクロで生成されたコードを見るためにはcargo-expandなどでひと手間かけて閲覧する必要がありますが、toastyの場合生のRustコードが所定のディレクトリに生成される関係で、実装中に生成されたコードを最悪見に行けますし、GitHub上でレビューできます。
  • 生成コードがバージョン管理される: 手続き的マクロで生成されるコードはバージョン管理できません。なので、たとえばクレートのバージョンを入れ替えたときなどに内部実装が変わっていた場合、どこに変更があったか追いにくくデバッグを困難にするかもしれません。生のRustコードの場合、これは非常に容易です。

Rustには、dieselや、Active RecordインスパイアなORMのSeaORMなどがあります。しかしこれらに共通する問題として、手続き的マクロを使用しているのでコンパイル時間を悪化させる要因になりがちというものがあります。dieselは私も業務利用経験がありますが、プロジェクトの規模が大きくなるにつれて本当にビルド時間時間がかかるようになります。ビルドが遅いのは、rust-analyzerを止めるので割と開発時間にも影響が出てきます。また、同じ問題はsqlxでマクロ側を使用した場合にも同様に発生するのではないかと思われます。toastyの場合は、まだ軽く触った限りなので何もわかりませんが、理論上はこうした問題を軽減できるはずです。

この手法のデメリットも少し考えてみましょう。一例をあげると、スキーマ定義がRustでは完結せず、toastyというある種の「設定ファイル」を書くことになる点はデメリットといえるかもしれません。たとえば上手な共通化や抽象化はRustコードでスキーマ定義を書く場合と比べるとできなくなります。しかし、こうした定義ファイルに対する共通化や抽象化はどこまで必要でしょうか?好みの問題は依然残りそうですが、実務上は同じ内容を繰り返して書いてもそこまで大きな問題は生じないと思います。

dieselではマクロでスキーマ定義を書きますが、マクロで書くので抽象化や共通化が効くのではと思いきや、ダーティハックしようとするとすぐにマクロの解決ができなくなります。マクロで書いたとしても共通化や抽象化できないとなると、単にコンパイル時間の延長というペインを引き受けるだけになります。

もうひとつ考えられることがあるとすれば、Rustコードの管理とtoastyファイルの管理が必要になるということでしょうか。これにより、Rustのコンパイルを行っただけでは最新のスキーマ定義に基づくコードが生成されないケースがある、という管理の煩雑さの増加が考えられます。Rustのコンパイルスキーマ情報が最新化されるわけではないので、もしかするとtoasty側の更新を忘れてそれに気づかずハマる、といったことが起こるかもしれません。これは些細なように見えて、意外に開発中に気づきにくいミスなような気がしています。私だけかもしれませんが。

シンタックスハイライトや補完などは、新しいファイル形式なため当然別途対応する必要があります。ただこれについては、Language Serverを軽く実装するか、もしくはRustRoverなどはそれ向けの専用のプラグインを用意すれば回避可能な問題なので、開発が進むにつれて改善されている話なように思われます。

気になるポイントとしては、toastyファイル内で起こった定義の不整合に対する出力方法です。現状のtoastyのコード生成部分の実装を確認したり、実際に異常系の挙動をさせたりした限りだと、あまりエラーを親切に出せる感じにはなっていません。おそらく今後大幅な改訂が入るのではないかと思われます。Rustコンパイラ級のわかりやすいエラーメッセージを出すように実装されるともちろん嬉しいので、今後の開発に期待しています。

動かしてみる

toastyそれ自体はまだcrates.ioに公開されていないようです。crates.ioに名前自体は登録されていますが、cargo addなどで追加したとしても機能を使うことはまだできません。

https://crates.io/crates/toasty

それでも少し試してみたいと思った場合、リポジトリをforkすることで利用できます。私が興味を持ったのは実際のコード生成の部分なので、それを確認してみます。試したのは記事を書いている時点で最新のコミットである 2a4f7175e9cf2f7e56cb545a5a9e9ffbbc291fb0 です。

scriptsを見ていると、gen-examplesという意味ありげなスクリプトが見つかりました。この後半に、

# Main loop to execute the command for each directory
for dir in "${directories[@]}"; do
    cargo run -p toasty-cli -- gen --schema "examples/$dir/schema.toasty" "examples/$dir/src/db"
    cargo check -p example-$dir
done

などという記述があります。examplesには現状、composite-keycratehubhello-toastyuser-has-one-profileのサンプルがあります。hello-toastyの生成されたコードを試しに再生成させてみます。

まず、dbディレクトリを削除します。次に、下記のコマンドを実行してtoasty-cliを動かします。すると、コードが確かに生成されているのを確認できるはずです。

$ cargo run -q -p toasty-cli -- gen --schema "examples/hello-toasty/schema.toasty" "examples/hello-toasty/src/db"
     writing    examples/hello-toasty/src/db/mod.rs
     writing    examples/hello-toasty/src/db/user.rs
     writing    examples/hello-toasty/src/db/todo.rs

確認すると、たとえばuser.rsには次のようなコードが生成されていることを確認できるはずです(長いのでRust Playgroundに置いています)。

play.rust-lang.org

生成済みコードでは、ORMに利用できる関数が逐次生成されている様子を伺うことができます。実際にLSPのGo To Definition機能で関数に入るとどう実装されているのかを見られてよいなと感じました。

実際の使い心地側は今回は説明しませんが、下記のリポジトリ上のコードを見てもらうとわかりやすいかと思います。

github.com

まとめ

tokioが先日発表したORM「toasty」を簡単に説明しました。コンパイル時間を伸ばしにくそうな設計になっているのはよかったなと思うので、今後に期待しています。

サーバーサイドの開発においては、サーバーサイド用のライブラリとORMを用意すれば一旦開発をはじめられることが多いように思います。たとえば、KotlinだとktorとExposedが開発されているように、です。なので、tokioがこれを作っているのは理にかなった話だと考えています。

余談ですが、まだ規模が小さくコードリーディングに良さそうです。ORMってどうやって作ってるんだろうなと前から思ってたんですが、sqlxもdieselも規模は大きいしコードは複雑で読むのが大変でやめてました。toastyはコードが読みやすいというかシンプルな作りになっているのと、単純に規模が小さいので読みやすかったです。暇を見つけてもう少しじっくりいろいろ読んでおきたい。

*1:この「生成」部分には、TokenStreamやquote!などが利用されています。なので、単に手続き的マクロの「コンパイル」が走るタイミングをズラしているだけという見方もできそうな気はします。それでも、コンパイルのたびに毎回走るよりかは幾分かマシになりそうな気はしますが…。

『RustによるWebアプリケーション開発 設計からリリース・運用まで』という本を共著で書きました

RustのWebアプリケーション開発に関する書籍を共著で執筆しました。1年くらい執筆していましたが、出版時期などが定まってきたので内容の紹介を込めて告知の記事を書きます。9/26刊行予定です。予約よろしくお願いします。

RustによるWebアプリケーション開発

AmazonのURL(アフィリエイトなし) www.amazon.co.jp

数年前に書籍を執筆した際に、「次はWebアプリケーションの実装に関する本を書きたい」と記事に書き残していたのを今見つけたのですが、有言実行できたようです。

どんな本か?

RustでWebアプリケーションの実装や運用を行うには?という疑問に答える一冊です。より応用的なRustの使い方、使えるクレートの紹介など、「現場で実際に使える」トピックを盛り込むことを意識しました。

まず大前提として、Rustの文法や機能の入門書ではありません。Rustの文法や機能に関する基本的な解説は一切含まれていません。なので、取り掛かる前にRustの入門書を一冊以上ないしは公式のチュートリアルをこなすなどして、基本的な使い方はキャッチアップすることをお勧めします。

本書はRustでいわゆるWebアプリケーションの「バックエンド」を開発する書籍です。HTTPをしゃべるサーバーを実装する話がメインです。axumというRustでは最近よく用いられるHTTPサーバーを実装できるライブラリを使って実装します。なお、以下本書で扱う開発の範囲を便宜的に「バックエンド開発」と称します。

バックエンド開発では、たとえば実装スピードを向上させたり保守性を高めたりするためによく利用されるテクニックがいくつかあると思います。しかし、Rustのコミュニティには、そうしたテクニックを使いながらRustでバックエンド開発を行うためにはどうすればよいのか?といった情報や、まずまずな規模のアプリケーションを実装し切るような知見がまだまだ不足しているのではないかという問題意識がありました。このバックエンド開発における「Rust入門以後」を進んでいくにあたり、ガイドとなるような一冊を目指しています。そういうわけで、すでに会社でRustでのバックエンド開発を経験している方には、あまり新しい発見はないかもしれません。

「Webアプリケーション」というとフロントエンドも思い浮かべるかもしれませんが、フロントエンドを扱わなかったのは、単純に著者らの実装や執筆の労力の都合と、おそらく仕事でRustを導入するとなるとバックエンドに導入することのほうがユースケースとしては多いのではないかと考えたためです。たしかにRustを使ってフロントエンドを実装することもできるといえばできるのですが、正直なところどれほど旨味があるでしょうか。また、開発現場の大多数ではReactやVueなどのフレームワークを用いたフロントエンド開発がすでに行われているはずです。こうした事情から、Rustによるフロントエンドの側面は意図的に弾いています。同様に「Webアプリケーション」の文脈で言及されがちなWebAssemblyに関する話もまったくといっていいほど登場させていません。

なので、「Webアプリケーション」と言ってもかなり限定的な範囲を扱った書籍であることに留意いただきたいと思います。あくまで、バックエンド開発にRustを用いるならという話に重点を置いています。

ところで、海外のRustコミュニティには『Zero to Production in Rust』というすばらしい「Rust入門以後」を支える一冊があります。この書籍は非常に詳しく極めて実践的な内容をバランスよく扱っており、私も多くの影響を受けています。ただこの書籍は英語で書かれたものであり、日本語圏のソフトウェアエンジニアには少しハードルが高く感じられるものでもあります[*1]

本書はまさに、『Zero to Production in Rust』にインスパイアされて書かれた一冊でもあります。この書籍を読んでおくと一旦、バックエンド開発の現場にRustを持ち込めるという状態を目指したものです。Rustの普及にはさまざまな障壁がありますし、私個人の意見としても、JavaPHPのような巨大な採用実績を持つことはなかなか難しいだろうなという肌感はあります。それでも、本書の出版によってRustがとりいれられる現場が増えるといいなと思っています。

『Zero to Production in Rust』もそうですが、本書もこれからRustでバックエンド開発を始めていきたい方々向けの内容を中心的に盛り込んでいます。すでに開発されている方はもしかすると新しい発見はそこまで多くないかもしれませんが、上記の中で何か気になるトピックがありましたらぜひお手に取っていただけますと幸いです。

Rustってバックエンド開発に向いてるの?

この問いはよく聞かれますが、裏の意図や意味をよく考えています。「Rustはみんなのバックエンド開発のファーストチョイスになりうるの?」という問いが、実際のところ意味的には近いでしょうか?とすると、答えは今のところはノーになります。一方で、「Rustでバックエンド開発って大変じゃないの?」という問いだとすると、答えは「そうでもないかもしれない」になると思います。色々な方向の問いの可能性がありすぎて、聞かれるたびに都度どういう意味かを聞き返しています😂。

あまり深く考えずに字面通り答えるなら、本書を書いた以上は「向いている」と言いたいところですが、私個人としては「Rustでも実装できるし運用できる」くらいの感覚を持っています。上述した通り、現代におけるJavaPHPRuby on Railsなどの立ち位置になるのはちょっと難しいのではないか?と思っています。そもそもシステムプログラミング言語ですしね。

詳しい議論はぜひ書籍を読んでいただきたいと思っていますが、書籍内では下記の観点から議論をしています。

  • Rustを入れるメリット
    • Rustの高いパフォーマンスの恩恵を受けることができる。
    • 非常に優れた開発者体験の恩恵を受けることができる。
  • Rustを入れる際の課題点
    • 知見の流通が少なく、知見を持つ人がいないと導入に苦労をしやすい。
    • 一通り開発し運用するのに十分なものは揃っているが、エコシステムそれ自体は細かい点においてはまだまだ発展途上である。
    • 「Rustのような低レイヤー向け言語の導入はオーバーキルなのでは?」という疑義にストレートに答えにくい。

どのプログラミング言語でもそうだとは思いますが、正直なところ慣れてしまえば書くこと自体に抵抗はなくなりますし、すらすら書けるようになってきます。時折コンパイラに怒られてライフタイムの調整に走ることはままありますが、その対処が実装時のかなりの時間を占めているかというとそうでもありません。

開発中は普通に他のプログラミング言語を書いているのと変わりないと感じています。むしろRustのShared XOR Mutability[*2]の原則などにより変数のスコープの整理が言語側の仕組みによって強制されるようになるので、結果的に見通しのいいコードになりやすいとすら感じます。

加えてrust-analyzerやclippyをはじめとするエディタやツールの支援も多いです。rust-analyzerは個人的には他のLanguage Serverと比較しても非常に完成度が高いと感じており[*3]IDEとまではいきませんがそれに近い開発体験を得られるはずです。clippyを使うとRustのidiomaticな記法を教えてもらうことができます。よく言われる話ですがコンパイルエラーも非常に読みやすいです。そういうわけで開発者体験は他のプログラミング言語と比べると群を抜いてるかなという印象です。

上記から、私個人はバックエンド開発ではRustはファーストチョイスになりえます。しかし、もうかれこれ7年近くRustを使っているため完全にバイアスがかかっています。この辺りをどう感じられたかは、ぜひ本書で実際にいろいろ手を動かしてみて、感想を寄せていただけると嬉しいかなと思っています。そうした感想の集まりが、結局のところ今後のRustの立ち位置を決めていくのだと思っています。

著者について

期間の長短はあれ、共著者の3名はRustを使ったバックエンド開発の経験を持っています。私自身は現在は実務ではRustから離れてしまっていますが、Rustで開発していた頃に本書の大多数は執筆されています。実務からは離れているものの、最近もOSSのコードをメンテナンスしているなど完全にRustから離れているわけではありませんが。その他の著者は今でも実務でRustを使用して開発を行っています。

共著者3名は『実践Rustプログラミング入門』という書籍を4年ほど前に執筆した関係性にあたります。出版後しばらくしたのちに吉川さんのWEB+DB PRESSのRust回でレビュワーとして少しお手伝いをしました。今回は講談社さん側からの私への声掛けで、当初は単著で取り組もうか悩んだのですが、単著ではなかなかモチベーションの維持が難しいだろうと思い共著で執筆しようと声をかけたのがきっかけです。

また、吉川さんの伝手でrust-jpや『実践Rust入門』の共著者でお馴染みの河野達也さんにレビューをお願いしています。河野さんには数多くの貴重なレビューやフィードバックをいただきました。中には考慮しないと致命的なものであったり、ときには実際にベンチマークをとっていただいたりなど大変ありがたいものも含まれています。

目次とトピックの簡単な紹介

目次は下記の通りです。

  • はじめに
  • 第1章 本書で開発するアプリケーション
  • 第2章 開発環境の構築
  • 第3章 最小構成アプリケーションの実装
  • 第4章 蔵書管理サーバーアプリケーションの設計
  • 第5章 蔵書管理サーバーの実装
  • 第6章 システムの結合とテスト
  • 第7章 アプリケーションの運用
  • 第8章 エコシステムの紹介

ちなみにですが、5章が一番長いです。

以下で各章の概略を説明します。

はじめに

Rustでバックエンド開発をするにあたり、どのようなメリットやデメリットが考えられるのかなどを簡単に記載しています。いわゆる導入の章にあたります。

第1章 本書で開発するアプリケーション

今回開発予定の蔵書管理アプリケーションの要件を整理する章です。

第2章 開発環境の構築

Rustの開発環境を準備するための章です。Visual Studio CodeないしはRust Roverを使った開発が期待されますが、どのエディタを用いたとしても基本的に問題はありません。なお私はNeovimですべて開発しました。

第3章 最小構成アプリケーションの実装

本書ではaxumというHTTPサーバーを実装するためのライブラリを用いて開発を進めます。まずはaxumで簡単なHTTPサーバーを立ててみる章になります。基本的に後続の章はこの章で実装した内容をベースに進行します。

また、HTTPサーバーを実装した後はsqlxを用いて簡単なデータベース接続をチェックできる機能を実装します。

アプリケーション本体の実装だけでなく、ユニットテストやCIの準備までを含みます。アプリケーション構築の最初の方からCIパイプラインを構築しておくと、ビルドやテストの成功/失敗のフィードバックサイクルを素早く回せるだろうという狙いから、この辺りから導入しています。

第4章 蔵書管理サーバーアプリケーションの設計

「設計」とタイトルに出てしまっていますが、ここで3章の実装内容をいわゆるレイヤードアーキテクチャを用いた実装に切り替えていきます。本書にレイヤードアーキテクチャを入れた意図としては、多くの開発現場でこうしたアーキテクチャが採用されているだろうという予測と、いわゆる「real world」な、現実のアプリケーションの設計に即した実装を用意しておきたかったからです。ここにはDIを含む現場で用いられるような設計手法がいくつかとりいれられています。

第5章 蔵書管理サーバーの実装

この章で蔵書管理アプリケーションを一気に実装しきります。実はコード量がかなり多くなってしまい、相当な機能数があるといえばあります。単純に蔵書を管理するだけでなく、蔵書管理アプリケーションに登録されているユーザー管理などもさせています。

第6章 システムの結合とテスト

テストに関する細かい話や結合テストに関する話題が書かれています。モックやデータベース周りのテストに関して、5章では一旦飛ばした内容が再度解説されます。

第7章 アプリケーションの運用

いわゆる「オブザーバビリティ」、「CI」、そして最後に残ったOpenAPIに関する話題を扱います。アプリケーションの運用効率を高めるためによく利用される技術が、Rustの開発においてはどう実現されるかという話を中心にまとめています。CIについては、とくに高速化の際使えそうなtipsを雑多にとりあげています。

第8章 エコシステムの紹介

この章は参照用の章です。本書で紹介したクレート以外に使えるクレートを紹介しています。

書籍を書いた感想など

今回は完全に日本のRustによるバックエンド開発シーンが盛り上がるとよいなという気持ちで書いています。現時点では本書に類似するような書籍はないと思っており、この書籍が市場に放たれることで、Rustによる本格的なバックエンド開発の知見が巷に流通するとよいなと思っています。

一方で実装がかなり大変だったかなと思っています。仕様を盛り込んでしまった関係で思ったより実装を完了させるのが大変でした。それに伴って書籍のある章のページ数が大きくなり過ぎており、この辺りを読者のみなさんが果たしてやり切れるのかどうか…は、著者としてはよくわからないなと思っています。

また、書籍の帯では「ベストプラクティス」などと銘打たれていますが、私個人の意見としては、本書の内容はあくまで「実装の一手段」として捉えて欲しいです。そもそもベストプラクティスという言葉はそんなに好きではないです。というのも、どの手段を採用するかは前提によるのが実務だろうと思っているからです。そこに存在するのは、トレードオフの検討を通じた「ベターな選択」の積み重ねであり、「ワースト」を選ばないことが大事なのです。何かが他の選択肢を否応なく排斥しうる「ベスト」になることはまずないからです。

そういうわけで、本書を読んで「こういう手があるんだな」と思っていただき、さらに自身で改良した手法を編み出して行ってもらえることを願っています。そして可能なら改良した手法をぜひインターネット上の記事に知見として放流してください!「This Week in Rust」にてお待ちしております。[*4]

ところで、Rustを入れる検討をしている会社さんがありましたらぜひお声掛けください。何か導入の支援などできますと嬉しいと思っています。

*1:もちろん原著の英語は非常に平易かつ明確で、そこまで読みこなしが難しいものでもないとは思いますが。

*2:ある参照の共有はできる。ある参照の変更もできる。ただしそれらを共存させられないというRustの仕組みです。

*3:これは私がkotlin-analyzerというKotlin向けのLanguage Serverを最近作ったり、作るための調査をしていたりした際に感じたことです。rust-analyzerのレベルの速度や安定性を持ったサーバーがなかなかないなと思っています。それもそのはず、相当な工夫が裏側には隠されています。このブログに結構おもしろいエピソードがたくさんあります。

*4:This Week in Rustは、GitHub上のリポジトリに記事のURLを追加するPull Requestを投げると記事を掲載することができます。普段はRedditやHacker Newsなどの有名どころから、編集者がキュレーションするなどして収集されていますが、読者からの応募も受け付けしています。英語の記事が多いですが、一応日本語の記事も投稿可能です。実は私は日本語記事のレビュワーを務めています。ほとんど日本語記事が来ることはありませんが…

Zedの設定をちゃんとやってみる

ここ数回の記事を見返してみると、書評ばかりしていてコード書いてるのか…?となったので、久しぶりにちゃんとコード(設定ファイル)を書く記事を書こうと思いました。いえ、コードは書いてるんですが、まとまった成果になっていないか、あまり新しいことをやっていないだけです。

Zed

Zed(ゼッド)というエディタが最近話題ですね。私も実は最近会社のPC上のNeovimが壊れてしまって、直している時間がないので一旦Zedを使ってその場しのぎをしています[*1]VS Codeを使わなかったのは、あんまりVimバインディングが強くないとわかっていたからです。あとで説明しますが、ZedはVimバインディングがまずまずよくできています。

zed.dev

ZedはRust製のエディタで、今のところ非常に高速に動作するのがウリです。プラグインを入れだすと遅くなるんじゃない?という話はあるとは思うんですが、プラグインを全然入れていない状態のZedとVS Codeを比較しても、VS Codeの方はちょっともっさりしていると感じることはある一方で、Zedはもっさりすることはまずないですね。

Rust製のツールはZed以外にも、たとえばCLIやTUIツールなどで人気ですが、特徴としてとにかく高速に動作し、画面の止まる感覚がないというのはあると思います。既存ツールをPythonやGoなどの言語からRustに置き換えたようなものを比較でいくつか使ってみていますが、圧倒的に画面が止まることが少ないです。こういうところで、やっぱりRustって速いんだなあと感じることが多いですね。

とってもどうでもいい話ですが、個人的には「ゼッド」と言われるとZeddの方が思い浮かんでしまって、開くたびにZeddを聴いてしまいます。チクタク。

www.youtube.com

Zedの設定方法と今回目標

settings.jsonとkeymap.json

設定ファイルの記述方法についてはドキュメントに詳しくまとまっているので、そちらを参照すると良いでしょう。

zed.dev

settings.jsonはZed全体の設定を記述するファイルです。たとえばテーマやフォント、vimモードをオンにするかといった設定を行うことができます。

keymap.jsonはキーマッピングの設定を記述するファイルです。私は今回は自分のNeovimに近い挙動をさせようとしていますが、たとえばIntelliJVS Codeなど使い慣れたエディタがあり、そのキーマップを引き継ぎたい場合は、サンプルとなるkeymap.jsonが用意されているようなので、自分の手元に落として使うとよいと思います。

snippet

この記事では一旦スキップしようと思いますが、いわゆる「スニペット」も設定可能なようです。要するに、よく書くコードのパターンをカスタマイズして登録しておくことができるというものです。throw Error(...)のような頻出イディオムを登録しておくことができます。最近はCopilotに完全に委ねていますが、あると便利なのかもしれません。

設定の目標

目標は手元のNeovimとほとんど同じキーマッピングであらゆる操作をできるようにすることです。テーマ設定をはじめとする普段の開発状況も可能な限り揃えておきたいと思います。

私の設定

今回は下記をやってみました。

  • Vimモードをオン。
  • いくつかの必要な設定をオン。
    • inlay hints
  • テーマをcatppuccinに変更。
  • フォントの調整。
    • サイズを変更する。
    • JetBrainsMono Nerd Font (JetBrainsMono NF)にする。
    • リガチャは有効にする。
  • キーマッピング

最終形態はこんな感じです。

セットアップ完了後

完成させると次のような設定ファイルになりました。まずはsettings.jsonからです。

{
  "theme": "Catppuccin Mocha - No Italics",
  "ui_font_size": 14,
  "buffer_font_family": "JetBrainsMono Nerd Font",
  "buffer_font_size": 14,
  "vim_mode": true,
  "relative_line_numbers": true,
  "scrollbar": {
    "show": "never"
  },
  "buffer_line_height": {
    "custom": 1.5
  },
  "inlay_hints": {
    "enabled": true,
    "show_type_hints": true,
    "show_parameter_hints": true,
    "show_other_hints": true,
    "edit_debounce_ms": 700,
    "scroll_debounce_ms": 50
  },
  "lsp": {
    "rust-analyzer": {
      "check": {
        "extraArgs": ["--target-dir", "target/ra"]
      },
      "initialization_options": {
        "check": {
          "command": "check"
        }
      }
    }
  },
  "terminal": {
    "alternate_scroll": "off",
    "blinking": "terminal_controlled",
    "copy_on_select": true,
    "font_family": "JetBrainsMono Nerd Font",
    "toolbar": {
      "title": true
    },
    "line_height": {
      "custom": 1.5
    },
    "working_directory": "current_project_directory"
  }
}

vim_mode、フォント、テーマ

最初のUIに関連する設定についてまずは見ていきます。主にはテーマ、フォント、そしてvim_modeに関する設定を施しています。

{
  "theme": "Catppuccin Mocha - No Italics",
  "ui_font_size": 16,
  "buffer_font_family": "JetBrainsMono Nerd Font",
  "buffer_font_size": 14,
  "vim_mode": true,
  "relative_line_numbers": true,
  "scrollbar": {
    "show": "never"
  },
  "buffer_line_height": {
    "custom": 1.5
  },
...

themeではCatppuccin Mochaというテーマを指定しています。私はNeovimやVS Code、ターミナルなどが軒並み全部Catppuccinを使っています。かわいくて好きです。

デフォルトでOne Darkは入っていそうですが、Catppuccinは追加でいろいろ設定が必要でした。Extensionとして配布されているCatpuccinをインストールしておく必要があります。ちなみにですが、settings.jsonに設定内容があれば何もせずとも読み取ってくれそうではあるものの、たとえばsettings.jsonで何も設定していない初回のセットアップの際などは、下記のようにテーマを手で選んでやる必要があります。

Select Themeでテーマを選ぶ。裏でsettings.jsonが修正されます。

フォントにはJetBrainsMono Nerd Fontをいつも使っています。リガチャありで使っていますが、普通の文字の方にはあまり飾りが多くなくて好きかなあという印象です。

"vim_mode": trueVimモードを利用できます。最初はこれだけsettings.jsonに書いて、あとでいろいろ調整しても遅くないと思います。Vimモードですが、定義ジャンプなど必要な機能はだいたい標準のキーマップで実行できるように設定されています。後で若干キーマップは調整を加えます。

relative_line_numbersで相対的な行番号表示に切り替えられます。これもNeovim側でオンにしていたのでこちらも合わせてオンにしています。

inlay hints

inlay hints利用シーンとして代表的なのは、変数の型情報の表示でしょう。近年のプログラミング言語は、型を明示せずとも型を推論して解決できるようになってきている関係で、変数に対する型の情報をコードに落とす必要がなくなってきています。しかし一方で、コードに型情報が落ちてこないと言うことは、ぱっと見でその変数にどのような型付けがされているかわかりにくくなることもあります。代表的なのはTypeScriptやRustでしょうか。私は普段Rustを使いますが、Rustも比較的激し目にいろいろ型付けする言語で、最終的にどのような型が生成されているのかをコードから追うのが難しい時があります。

inlay hintsを利用すると、変数の横に型情報が表示されるようになります。他には関数のパラメータ名を表示してくれたり、メソッドチェーンが連なる横に今どのような型付けになっているかを表示してくれたりします。メソッドチェーンの多いRustでは手放せない機能でもあります。

inlay hints自体は実はLSPに定義があります。したがって、使っているLanguage Serverがinlay hintsに対応している必要があります。Zedはその情報を受け取って表示しているに過ぎません。

私はinlay hintsそれ自体は普通に便利だと思っているので、NeovimでもVS CodeでもJet Brainsでもオンにしていた派です。したがって下記のように設定しました。基本的に全部オンにしています。

  "inlay_hints": {
    "enabled": true,
    "show_type_hints": true,
    "show_parameter_hints": true,
    "show_other_hints": true,
    "edit_debounce_ms": 700,
    "scroll_debounce_ms": 50
  },

注意点ですが、Zedのinlay hintsの表示はVS Codeのそれと比べると結構ノイジーです。TypeScriptなどを書いていると、型情報が100文字くらい平気でいくことがありますが、現状のZedの実装ではそれをすべて表示してしまいます。一応Issueは立てられていますが、まだ進む気配はないですね。試してはいないんですが、Language Server側に設定があるようであればそちらで表示数を削る設定を有効化しなければならないかもしれません。

rust-analyzer

Zedとは直接関係がないんですが、lspというセクションにLSPごとの設定も仕込めるようです。Zedそれ自体は正直RustかGoを書く時くらいしか使わないかなと思っているのですが、一旦rust-analyzerの設定だけ仕込んであります。

  "lsp": {
    "rust-analyzer": {
      "check": {
        "extraArgs": ["--target-dir", "target/ra"]
      }
    }
  },

Rustacean.nvimなんかではcargo clippyをrust-analyzerの解析で走らせてたりするみたいなんですが、そうしたい場合には下記のようにclippyを設定します。私は重くて嫌なのでcargo checkを走らせています。たしかデフォルトはcargo checkだったと思ってるんですが、念の為設定を上書きしています。extraArgs--target-dirを設定してやると、rust-analyzer専用の成果物を置くディレクトリを用意させられます。これをやっておくと、rust-analyzerの解析とターミナル等で走らせたcargo runなどがバッティングして、ビルドがブロックされてしまう問題を回避することができます。

      "initialization_options": {
        "check": {
          "command": "check"
        }
      }

キーバインディング

さてキーバインディングですが、下記のように設定しています。ひとつひとつつまんでいると記事を書くのが大変そうなので、要所だけ押さえておきます。

[
  {
    "context": "Editor && (vim_mode == normal || vim_mode == visual) && !VimWaiting && !menu",
    "bindings": {
      "ctrl-h": ["workspace::ActivatePaneInDirection", "Left"],
      "ctrl-l": ["workspace::ActivatePaneInDirection", "Right"],
      "ctrl-k": ["workspace::ActivatePaneInDirection", "Up"],
      "ctrl-j": ["workspace::ActivatePaneInDirection", "Down"],
      "space c": "pane::CloseActiveItem",
      "space b c": "pane::CloseInactiveItems",
      "space b C": "pane::CloseAllItems",
      "space e": "workspace::ToggleLeftDock",
      "space f f": "file_finder::Toggle",
      "space l a": "editor::ToggleCodeActions",
      "space l d": "diagnostics::Deploy",
      "space l f": "editor::Format",
      "space l s": "outline::Toggle",
      "space l r": "editor::Rename",
      "space o": "tab_switcher::Toggle",
      "space t h": "workspace::OpenInTerminal",
      "space t f": "workspace::NewCenterTerminal",
      "space /": "editor::ToggleComments",
      "g d v": "editor::GoToDefinitionSplit",
      "g r": "editor::FindAllReferences",
      ">": "vim::Indent"
    }
  },
  {
    "context": "Terminal",
    "bindings": {
      "ctrl-k": ["workspace::ActivatePaneInDirection", "Up"],
      "ctrl-j": ["workspace::ActivatePaneInDirection", "Down"]
    }
  }
]

キーバインディングの設定は、二つのセクションからなるオブジェクトで構成されています。

  • context: コンテクスト。Zedのどの場所にどの状態で差し掛かったらそのキーバーインディングを使用するか、でしょうか。contextは幅広く指定できるようで、Editorはコードを記述する場所、TerminalはZed内で起動されるターミナルにいる際、他は左に表示されるファイルエクスプローラー的な場所にいる場面、などほとんどすべてのZed上の要素を指定できそうです。このページで指定できるcontextの一覧を見ることができます→Key bindings - Zed
  • bindings: そのコンテクストで有効化するキーマッピングを定義します。

IntelliJVimバインディングだと正直コートエディタ内くらいまでしかまともに動かせない印象ですが、Zedの場合ほとんどすべてに対してキーマッピングを指定できます。非常に柔軟性が高く、手元のNeovimとほとんど同じ動作をさせるところまでたどり着くことができました。いわゆるリーダーキーを使った操作(私の場合は、<leader> = spaceでした)も定義することができて割と満足です。

Vimプラグインをたくさん入れている方の場合は、設定の自由度に物足りなさが多少あるかもしれませんが、私はそんなに詳しくないのもあり、あまりプラグインを多くは入れていない状態でNeovimを使っていたので一旦このキーマッピングで満足しています。

設定してみての感想

JetBrains系やVS Codeよりかは自由度高く設定できる印象です。おまけに今のところは非常に高速に動作するので満足しました。

欲しいのはFloating Windowなんですがこれは難しいんですかね。現状はターミナルを立ち上げ後、Shift + Escを押すと擬似的にそれっぽいことはできるんですが、Floating Window欲しいなという気持ちはあります。

git周りのサポートはまだこれから発展途上という感じではあるんですが、そもそも普段lazygitを使っているのであんまり問題ありませんでした。ターミナルを立ち上げ後、↑で示した方法でlazygitを操作すればほとんどNeovimのころと変わらない体験が得られました。

本当は仕事でも使いたいんですが、Kotlinはあんまりいい感じに動いてくれないのでまだIntelliJが手放せそうです。GoやTypeScriptの開発には積極的に利用したいんですが、TypeScriptでの開発時にはinlay hintsを切らないと型情報でコードエディタが爆発します。

それでは、Neovimの壊れてしまった部分を直す旅に出てきます。

参考資料

*1:日中の8割〜9割はIntelliJで過ごしていますが、時々Neovimで開いた方が早かったり一瞬使いたくなったりするケースがあります。ただ、そうした小時間のために直していると時間がもったいないので…といった理由です。