Tatsuro のテックブログ

アプリ開発の技術情報を発信します。

【Android × Kotlin】Data Binding でレイアウトとリスナーメソッドをバインドする

今回は、Data Binding でレイアウトとリスナーメソッドをバインドする方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Electric Eel | 2022.1.1 Patch 1
  • JDK 11.0.15
  • Android Gradle Plugin 7.4.1
  • Kotlin 1.8.0
  • Gradle 7.6.0
  • androidx.lifecycle 2.5.1

概要

前回、Data Binding でレイアウトと StateFlow で保持する状態をバインドする方法を解説しましたが、Data Binding を使ってレイアウトとリスナーメソッドをバインドすることもできます。

例えば、ViewModel に Button がクリックされたときに呼びたいメソッドを定義し、Activity や Fragment などで View#setOnClickListener を呼ぶことなく、レイアウトの実装だけで ViewModel のメソッドをバインドすることができます。

これにより、Activity や Fragment などの実装を減らしながら、View のリスナーメソッドを ViewModel に実装することができます。

今回は、Button と Switch のリスナーメソッドの実装を通して、Data Binding でレイアウトとリスナーメソッドをバインドする方法を解説します。

ライブラリのインポート

Data Binding を有効にする場合、アプリの build.gradle ファイルの android ブロックに以下の設定を追加します。

android {
    buildFeatures {
        dataBinding true
    }
}

また、今回の解説では ViewModel も使用するため、以下の依存関係を追加します。

dependencies {
    // AndroidX Lifecycle
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
}

以上で、ライブラリのインポートは完了です。

実装

Data Binding でリスナーメソッドをバインドする場合、メソッド参照とリスナーバインディングの 2 つの方法があります。それぞれについて、解説します。

メソッド参照

メソッド参照でレイアウトとリスナーメソッドをバインドする場合、バインド先の View のリスナーメソッドと実装するリスナーメソッドのメソッドシグネチャを完全に一致させる必要があります。

まず、ViewModel を作成して、その ViewModel にリスナーメソッドを実装します。

class MainViewModel : ViewModel() {

    fun onButtonClick(
        @Suppress("UNUSED_PARAMETER") view: View
    ) {
        Log.d("MainViewModel", "Button clicked.")
    }

    fun onSwitchCheckedChanged(
        @Suppress("UNUSED_PARAMETER") buttonView: CompoundButton,
        @Suppress("UNUSED_PARAMETER") isChecked: Boolean
    ) {
        Log.d("MainViewModel", "Switch checked changed.")
    }
}

Button のクリックリスナーは引数に View を持つため、ViewModel のリスナーメソッドもそれに合わせます。Switch のチェック変更リスナーも同様に引数を合わせます。

そして、レイアウトは以下のように実装します。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity">

    <data>

        <variable
            name="viewModel"
            type="com.tatsuro.app.databindinglistenermethod.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <Button
            android:id="@+id/sampleButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:onClick="@{viewModel::onButtonClick}"
            android:text="Button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.google.android.material.switchmaterial.SwitchMaterial
            android:id="@+id/sampleSwitch"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onCheckedChanged="@{viewModel::onSwitchCheckedChanged}"
            android:text="Switch"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/sampleButton" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

リスナーを設定する属性に、@{} 構文とメソッド参照を組み合わせて設定します。

以上で、View でイベントが発生したときにメソッド参照でバインドされた ViewModel のリスナーメソッドが呼ばれます。

リスナーバインディング

メソッド参照と異なり、リスナーバインディングではメソッドの引数が一致する必要はなく、戻り値だけが一致していればよいです。

まず、ViewModel を作成して、その ViewModel にリスナーメソッドを実装します。

class MainViewModel : ViewModel() {

    fun onButtonClick() {
        Log.d("MainViewModel", "Button clicked.")
    }

    fun onSwitchCheckedChanged() {
        Log.d("MainViewModel", "Switch checked changed.")
    }
}

リスナーメソッドは引数なしで実装します。

そして、レイアウトは以下のように実装します。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity">

    <data>

        <variable
            name="viewModel"
            type="com.tatsuro.app.databindinglistenermathod2.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <Button
            android:id="@+id/sampleButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:onClick="@{() -> viewModel.onButtonClick()}"
            android:text="Button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <com.google.android.material.switchmaterial.SwitchMaterial
            android:id="@+id/sampleSwitch"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onCheckedChanged="@{() -> viewModel.onSwitchCheckedChanged()}"
            android:text="Switch"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/sampleButton" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

リスナーバインディングの場合、@{} 構文とラムダ式を組み合わせて設定します。

以上で、View でイベントが発生したときにリスナーバインディングでバインドされた ViewModel のリスナーメソッドが呼ばれます。

参考

レイアウトとバインディング式 - イベント処理

関連記事

Data Binding でレイアウトと StateFlow をバインドする方法については、以下で解説しています。

tatsurotech.hatenablog.com

ViewModel の使用方法については、以下で解説しています。

tatsurotech.hatenablog.com

【Android × Kotlin】Data Binding でレイアウトと StateFlow をバインドする

今回は、Data Binding でレイアウトと StateFlow をバインドする方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Electric Eel | 2022.1.1
  • JDK 11.0.15
  • Android Gradle Plugin 7.4.0
  • Kotlin 1.8.0
  • Gradle 7.6.0
  • org.jetbrains.kotlinx:kotlinx-coroutines-android 1.6.4
  • androidx.lifecycle 2.5.1

概要

画面の表示が可変であり、その状態を ViewModel の StateFlow で保持する場合、事前に画面のレイアウトとその StateFlow をバインドすることで、その StateFlow で保持する状態が自動的に画面に反映されるようになります。

今回は、Button をクリックした回数を表示するアプリの作成を通して、Data Binding でレイアウトと StateFlow をバインドする方法を解説します。

ライブラリのインポート

Data Binding を有効にする場合、アプリの build.gradle ファイルの android ブロックに以下の設定を追加します。

android {
    buildFeatures {
        dataBinding true
    }
}

また、今回の解説では ViewModel と StateFlow も使用するため、以下の依存関係を追加します。

dependencies {
    // Kotlin Coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
    // AndroidX Lifecycle
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
}

以上で、ライブラリのインポートは完了です。

ViewModel の実装

最初に ViewModel を実装します。この ViewModel では、Button のクリック回数を StateFlow で保持し、クリック回数をカウントアップするメソッドを設けます。

class MainViewModel : ViewModel() {

    /** Button のクリック回数 */
    private val _count = MutableStateFlow("0")
    val count = _count.asStateFlow()

    /** クリック回数をカウントアップする。 */
    fun countUp() {
        val iCount = count.value.toInt()
        _count.value = (iCount + 1).toString()
    }
}

まず、Button のクリック回数は文字列型で保持します。これは、Data Binding でレイアウトとバインドする際、表示するデータは文字列型でなければいけないからです。

/** Button のクリック回数 */
private val _count = MutableStateFlow("0")
val count = _count.asStateFlow()

そして、クリック回数をカウントアップするメソッドは以下のように実装します。

/** クリック回数をカウントアップする。 */
fun countUp() {
    val iCount = count.value.toInt()
    _count.value = (iCount + 1).toString()
}

以上で、ViewModel の実装は完了です。

レイアウトの実装

次に画面のレイアウトファイルを以下のように実装します。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="viewModel"
            type="com.tatsuro.app.databindingbasic.MainViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Count up"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{viewModel.count, default=0}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/button" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

まず、レイアウトのルートタグを layout にします。こうすることで、このレイアウトは Data Binding が有効になります。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".MainActivity">

    <!--  略  -->

</layout>

次に、layout タグの中に data タグを追加します。この data タグには変数を定義します。name 属性には変数名を設定します。type 属性にはこの変数の型を設定します。今回、ViewModel で保持する状態を画面に表示するため、さきほど実装した ViewModel を type 属性に設定します。

<data>

    <variable
        name="viewModel"
        type="com.tatsuro.app.databindingbasic.MainViewModel" />
</data>

そして、Button のクリック回数を表示する TextView は以下のように実装します。

さきほどの data タグに定義した変数を参照する場合、@{} 構文を使用します。この @{} 構文には、参照する変数と、Layout Editor の Preview に表示するデフォルト値を設定します。(このデフォルト値は省略できます。)

今回は viewModel の count プロパティを TextView に表示するため、android:text="@{viewModel.count, default=0}" と実装します。

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@{viewModel.count, default=0}"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/button" />

以上で、レイアウトの作成は完了です。

Activity や Fragment の実装

最後に Activity や Fragment を実装します。Activity や Fragment では、Data Binding のインスタンスを取得し、そのインスタンスに必要な設定を行います。

なお、Activity と Fragment ではそれぞれ実装に異なる箇所があるので、それぞれ解説します。

Acticity の実装

Activity の場合、以下のように実装します。

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val viewModel =
            ViewModelProvider(this)[MainViewModel::class.java]

        DataBindingUtil.setContentView<MainActivityBinding>(this, R.layout.main_activity)
            .let {
                it.lifecycleOwner = this
                it.viewModel = viewModel
                it.button.setOnClickListener {
                    viewModel.countUp()
                }
            }
    }
}

まず、Activity にレイアウト ID を設定する際、Activity#setContentView ではなくて DataBindingUtil.setContentView を使用します。DataBindingUtil.setContentView を使用して Data Binding のインスタンスを取得します。

そして、Data Binding のインスタンスには、LifecycleOwner とレイアウトで定義した変数、リスナーメソッドを設定します。

LifecycleOwner には Activity のインスタンスを設定します。この LifecycleOwner は StateFlow や LiveData で保持する状態を自動的に画面に反映するために必要な設定です。

レイアウトで定義した変数については、今回レイアウトで ViewModel を定義しているので、ViewModel のインスタンスを設定します。

最後に、Button のクリックリスナーに ViewModel のメソッドを設定します。

Fragment の実装

Fragment の場合、以下のように実装します。

class MainFragment : Fragment(R.layout.main_fragment) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val viewModel =
            ViewModelProvider(this)[MainViewModel::class.java]

        MainFragmentBinding.bind(view)
            .let {
                it.lifecycleOwner = viewLifecycleOwner
                it.viewModel = viewModel
                it.button.setOnClickListener {
                    viewModel.countUp()
                }
            }
    }
}

Fragment で異なる箇所は、Data Binding インスタンスの取得方法と LifecycleOwner です。

まず、Data Binding インスタンスの取得は DataBindingUtil.setContentView ではなくて Data Binding クラスの bind メソッドを使用します。

Activity のときに使用した DataBindingUtil.setContentView は Activity にレイアウト ID を設定するメソッドであるため、Fragment では使用できません。そのため、Fragment の場合には Data Binding クラスの bind メソッドを使用して、Data Binding インスタンスを取得します。

なお、この Data Binding クラスはレイアウトファイルごとに自動生成されます。そのクラス名はレイアウトファイルのファイル名のパスカルケースに、末尾に Binding が付与されたものになります。例えば、レイアウトファイル名が main_fragment の場合、その Data Binding クラスのクラス名は MainFragmentBinding になります。よって、Data Binding インスタンスを取得する際は、MainFragmentBinding.bind(view) になります。

そして、LifecycleOwner には Fragment のインスタンスではなくて viewLifecycleOwner を設定します。Fragment には Fragment 自身と Fragment の上にある View の 2 つのライフサイクルが存在します。StateFlow で保持する状態を反映する先が Fragment の View になるため、LifecycleOwner には viewLifecycleOwner を設定します。

参考

データ バインディング ライブラリ

関連記事

ViewModel の使用方法については、以下で解説しています。

tatsurotech.hatenablog.com

StateFlow の使用方法については、以下で解説しています。

tatsurotech.hatenablog.com

【Android × Kotlin】StateFlow で値を監視する

今回は、StateFlow で値を監視する方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Electric Eel | 2022.1.1
  • JDK 11.0.15
  • Android Gradle Plugin 7.4.0
  • Kotlin 1.8.0
  • Gradle 7.6.0
  • org.jetbrains.kotlinx:kotlinx-coroutines-android 1.6.4
  • androidx.lifecycle 2.5.1

StateFlow とは?

StateFlow は状態保持に特化した Flow です。最新の値のみ保持する特徴を持っているため、UI の状態保持に最適で、この Flow で UI の状態を保持することで、画面の表示を常に最新に維持することができます。

また、Android Jetpack には Activity や Fragment などのライフサイクルを考慮して suspend 関数を呼ぶ関数が用意されています。よって、Activity や Fragment がフォアグラウンドに表示されているときだけ StateFlow から値を収集することが可能であり、これにより、メモリリークやクラッシュを回避できます。

StateFlow を使う

実際に StateFlow を試します。

ViewModel の解説のときと同様に、Button をクリックした回数を ViewModel に保持し、その回数を Activity の TextView に表示してみます。

このとき、Button のクリック回数を素の Int 型で保持するのではなくて、StateFlow で保持します。

ライブラリのインポート

Android で StateFlow を使う場合、アプリの build.gradle ファイルに次の依存関係を追加します。

dependencies {
    // Kotlin Coroutines
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
    // AndroidX Lifecycle
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
}

まず、Android で StateFlow を使う場合には org.jetbrains.kotlinx:kotlinx-coroutines-android をインポートします。また、以降のサンプルで StateFlow を ViewModel に保持するためにlifecycle-viewmodel-ktx をインポートし、Activity で安全に StateFlow の値を収集するために lifecycle-runtime-ktx をインポートします。

ViewModel で値を保持する

まず、ViewModel を以下のように実装します。

class MainViewModel : ViewModel() {

    /** Button のクリック回数 */
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    /** クリック回数をカウントアップする。 */
    fun countUp() {
        _count.value = count.value + 1
    }
}

最初に、StateFlow で値を保持する箇所を見ていきます。

    /** Button のクリック回数 */
    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

StateFlow には大きく分けて MutableStateFlow と StateFlow の 2 つのクラスが存在します。この 2 つは以下のような違いがあります。

クラス 特徴
MutableStateFlow 値を変更できる。
StateFlow 値を変更できない。

StateFlow を扱う場合、値の変更は ViewModel 内部に留めて、値の変更通知を受け取る Activity や Fragment などではその値を変更できないようにします。

よって、値そのものは private が付与された MutableStateFlow で保持して、それを StateFlow に変換したものを ViewModel の外部に公開します。

次に、クリック回数をカウントアップするメソッドを見ていきます。

    /** クリック回数をカウントアップする。 */
    fun countUp() {
        _count.value = count.value + 1
    }

MutableStateFlow と StateFlow には value プロパティというものがあり、このプロパティを使って値の取得や設定ができます。

Activity で値の変更通知を受け取る

そして、Activity を以下のように実装します。

class MainActivity : AppCompatActivity(R.layout.main_activity) {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val viewModel =
            ViewModelProvider(this)[MainViewModel::class.java]

        // ボタンがクリックされるたびに、クリック回数をカウントアップする。
        findViewById<Button>(R.id.button)
            .setOnClickListener {
                viewModel.countUp()
            }

        // クリック回数が変更されたら、TextView を更新する。
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.count.collect {
                    findViewById<TextView>(R.id.text).text = it.toString()
                }
            }
        }
    }
}

まず、Button がクリックされたら ViewModel のカウントアップメソッドを呼びます。

        // ボタンがクリックされるたびに、クリック回数をカウントアップする。
        findViewById<Button>(R.id.button)
            .setOnClickListener {
                viewModel.countUp()
            }

そして、ViewModel のクリック回数の変更通知を監視して、クリック回数を TextView に設定します。

        // クリック回数が変更されたら、TextView を更新する。
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.count.collect {
                    findViewById<TextView>(R.id.text).text = it.toString()
                }
            }
        }

クリック回数を保持する StateFlow は Flow なので collect メソッドで収集します。

しかし、直接収集すると、Activity が破棄された後やバックグラウンドにいるときも収集してしまいます。

そこで、Activity の lifecycleScope と repeatOnLifecycle メソッドを使います。

Activity の lifecycleScope は Activity の CoroutineScope であり、Activity が破棄されると実行中の Coroutine がキャンセルされます。

そして、repeatOnLifecycle メソッドはライフサイクルが指定された状態になったときに渡されたブロックを呼びます。

今回のサンプルの場合、ライフサイクルが Start から Stop の間にブロックを呼びます。

この repeatOnLifecycle のブロックの中でクリック回数の StateFlow を収集し、 収集したクリック回数を TextView に設定します。

StateFlow VS LiveData

StateFlow と似た機能を持つものとして、LiveData があります。

StateFlow は LiveData と比較すると、値を null 安全で保持することができるため、StateFlow の方を積極的に使うとよいでしょう。

参考

StateFlow と SharedFlow

関連記事

ViewModel の使用方法については、以下で解説しています。

tatsurotech.hatenablog.com

LiveData で値を監視する方法については、以下で解説しています。

tatsurotech.hatenablog.com

【Spring Boot × Kotlin】MVC のコントローラクラスを MockMvc でテストする

今回は、MVC のコントローラクラスを MockMvc でテストする方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • IntelliJ IDEA 2022.2.3 (Community Edition)
  • JDK 17.0.4.1
  • Kotlin 1.6.21
  • Gradle 7.5
  • Spring Boot 2.7.4

プロジェクト作成

プロジェクトを Spring Initializr にて作成します。各設定項目は以下のように設定します。

  • Project: Gradle Project
  • Language: Kotlin
  • Spring Boot: 2.7.4
  • Project Metadata: デフォルト値
  • Dependencies: Spring Web, Thymeleaf

モデルの属性値をテストする

以下のようなコントローラクラスをテストします。このクラスは、クエリパラメータから受け取った文字列をモデルの属性に追加するクラスです。

@Controller
@RequestMapping("/greeting")
class GreetingController {

    @GetMapping
    fun greeting(
        @RequestParam(
            name = "name", required = false, defaultValue = "world"
        ) name: String,
        model: Model
    ): String {
        model["name"] = name
        return "index"
    }
}

上記クラスのテストコードを以下のように書きます。

@WebMvcTest(GreetingController::class)
class GreetingControllerTest @Autowired constructor(
    private val mockMvc: MockMvc
) {

    @Test
    fun shouldReturnDefaultValue() {
        mockMvc.get("/greeting")
            .andDo {
                print()
            }.andExpect {
                status { isOk() }
                content { contentType("text/html;charset=UTF-8") }
                view { name("index") }
                model { attribute("name", `is`("world")) }
            }
    }
}

まず、コントローラクラスのテストクラスに @WebMvcTest を付与し、テスト対象のコントロールクラスのクラスリテラルを設定します。そして、テストクラスのコンストラクタで MockMvc のインスタンスをコンストラクタインジェクションで受け取ります。なお、コントローラクラスのコンストラクタインジェクションとは異なり、コンストラクタの @Autowired を省略することはできません。

@WebMvcTest(GreetingController::class)
class GreetingControllerTest @Autowired constructor(
    private val mockMvc: MockMvc
) {
    ︙
}

次に、テストメソッドを実装します。まず、MockMvc に対して /greeting への GET リクエストを送信させます。

    @Test
    fun shouldReturnDefaultValue() {
        mockMvc.get("/greeting")
            ︙
    }

なお、MockMvc に HTTP リクエストを送信させる関数は以下の通りです。

関数 HTTP リクエス
MockMvc.get GET
MockMvc.post POST
MockMvc.put PUT
MockMvc.patch PATCH
MockMvc.delete DELETE
MockMvc.options OPTIONS
MockMvc.head HEAD

次に、MockMvc に実行させるアクションを andDo で設定します。今回はテスト結果を標準出力させるために print を呼び出します。

    @Test
    fun shouldReturnDefaultValue() {
        mockMvc.get("/greeting")
            .andDo {
                print()
            }
            ︙
    }

そして、GET リクエストを送信させたときの期待値を andExpect で設定します。今回は以下を確認します。

確認項目 設定
ステータスコード status { isOk() }
Content-Type content { contentType("text/html;charset=UTF-8") }
ビューの名前 view { name("index") }
モデルの属性値 model { attribute("name", is("world")) }
    @Test
    fun shouldReturnDefaultValue() {
        mockMvc.get("/greeting")
            .andDo {
                print()
            }.andExpect {
                status { isOk() }
                content { contentType("text/html;charset=UTF-8") }
                view { name("index") }
                model { attribute("name", `is`("world")) }
            }
    }

参考

MockMvc と @MockBean で Web レイヤーテスト

【Spring Boot × Kotlin】REST API を作成する

今回は、Spring Boot と Kotlin で REST API を作成する方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • IntelliJ IDEA 2022.2.2 (Community Edition)
  • JDK 17.0.4.1
  • Kotlin 1.6.21
  • Gradle 7.5
  • Spring Boot 2.7.3

プロジェクト作成

プロジェクトを Spring Initializr にて作成します。各設定項目は以下のように設定します。

  • Project: Gradle Project
  • Language: Kotlin
  • Spring Boot: 2.7.3
  • Project Metadata: デフォルト値
  • Dependencies: Spring Web

今回、REST API のルーティングを行うため、Dependencies に Spring Web を設定しています。

以上で、プロジェクトの作成は完了です。

REST API を作成する

プロジェクトの作成が完了したら、実際に REST API の作成に入ります。

今回、以下のような JSON を応答する REST API を作成します。

{
  "datetime": "yyyy-mm-ddThh:MM:ss.SSSSSS",
  "message": "Hello, {name}!"
}

datetime はバックエンドがリクエストを受けたときの日時とします。また、message の文字列の一部をクライアントからリクエストできるようにします。

今回は、以下の 3 つのデータの渡し方について、それぞれ実装します。

  • URL クエリパラメータ
  • パスパラメータ
  • JSON リクエストボディ

URL クエリパラメータ

最初に、URL クエリパラメータにてデータを渡す方法です。具体的には、以下のような REST API を実装します。

GET http://localhost:8080/get-greeting/query?name={name} HTTP/1.1

まず、REST API のルーティングをコントロールするクラスを以下のように実装します。

// GreetingController.kt
@RestController
class GreetingController {

    @GetMapping("/get-greeting/query")
    fun getGreetingByQuery(
        @RequestParam(
            name = "name", required = false, defaultValue = "world"
        ) name: String
    ) = GreetingResponse(
        datetime = LocalDateTime.now().toString(),
        message = "Hello, $name!"
    )
}

REST API のコントローラクラスには @RestController を付与します。このコントローラクラスには、HTTP リクエストごとにメソッドを追加します。

GET リクエストに対応するメソッドには、@GetMapping を付与し、そのリクエストのパスを @GetMapping の引数に設定します。

メソッドの引数には URL クエリパラメータを受け取る name を追加します。省略された場合のデフォルト値は "world" とします。

そして、メソッドの戻り値には JSON レスポンスに対応するデータクラスのインスタンスを設定します。このレスポンスのデータクラスは以下のように実装します。

// GreetingResponse.kt
data class GreetingResponse(
    val datetime: String,
    val message: String,
)

プロジェクトを実行すると、以下のようなコマンドを実行できます。

$ curl 'http://localhost:8080/get-greeting/query?name=tatsuro'
{"datetime":"2022-09-24T13:14:41.071979","message":"Hello, tatsuro!"}

期待通りの JSON を受け取れています。

パスパラメータ

次に、パスパラメータにてデータを渡す方法です。具体的には、以下のような REST API を実装します。

GET http://localhost:8080/get-greeting/path/{name} HTTP/1.1

REST API のコントローラクラスを以下のように実装します。

// GreetingController.kt
@RestController
class GreetingController {

    @GetMapping("/get-greeting/path/{name}")
    fun getGreetingByPath(
        @PathVariable("name") name: String
    ) = GreetingResponse(
        datetime = LocalDateTime.now().toString(),
        message = "Hello, $name!"
    )
}

実装は URL クエリパラメータとほぼ同じですが、@GetMapping に設定するリクエストのパスと、引数のアノテーションが異なります。

@GetMapping に設定するパスについては、パラメータになる箇所を波括弧で囲い、波括弧の中にパラメータ名を設定します。

メソッドの引数には、パスパラメータを受け取る name@PathVariable("name") を付与します。

なお、メソッドの戻り値に使用するデータクラスは、さきほどの GreetingResponse を流用します。

プロジェクトを実行すると、以下のようなコマンドを実行できます。

$ curl 'http://localhost:8080/get-greeting/path/tatsuro'
{"datetime":"2022-09-24T13:14:54.326179","message":"Hello, tatsuro!"}

期待通りの JSON を受け取れています。

JSON リクエストボディ

最後に、JSON のリクエストボディにてデータを渡す方法です。具体的には、以下のような REST API を実装します。

POST http://localhost:8080/post-name HTTP/1.1

{"name":"{name}"}

まず、JSON リクエストボディに対応するデータクラスを以下のように実装します。

// NameRequest.kt
data class NameRequest(val name: String)

次に、REST API のコントローラクラスを以下のように実装します。

// GreetingController.kt
@RestController
class GreetingController {

    @PostMapping("/post-name")
    fun postName(
        @RequestBody request: NameRequest
    ) = GreetingResponse(
        datetime = LocalDateTime.now().toString(),
        message = "Hello, ${request.name}!"
    )
}

今回は POST リクエストで実装するので、メソッドには @PostMapping を付与します。

JSON リクエストボディを受け取るためには、@RequestBody を付与した引数を追加します。この引数の型には、JSON リクエストボディに対応するデータクラス NameRequest を使います。

なお、メソッドの戻り値に使用するデータクラスは、いままで使った GreetingResponse を流用します。

プロジェクトを実行すると、以下のようなコマンドを実行できます。

$ curl -H 'Content-Type:application/json' \
    -X POST \
    -d '{"name":"tatsuro"}' \
    'http://localhost:8080/post-name'
{"datetime":"2022-09-24T13:15:12.659395","message":"Hello, tatsuro!"}

期待通りの JSON を受け取れています。

参考

REST API の作成

【Spring Boot × Kotlin】Thymeleaf で Web ページを作成する

今回は、Thymeleaf で Web ページを作成する方法を解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • IntelliJ IDEA 2022.2.2 (Community Edition)
  • JDK 17.0.4.1
  • Kotlin 1.6.21
  • Gradle 7.5
  • Spring Boot 2.7.3

プロジェクト作成

プロジェクトを Spring Initializr にて作成します。各設定項目は以下のように設定します。

  • Project: Gradle Project
  • Language: Kotlin
  • Spring Boot: 2.7.3
  • Project Metadata: デフォルト値
  • Dependencies: Spring Web, Thymeleaf

Web ページを作成するため、Dependencies に Spring Web と Thymeleaf を設定しています。

まず、Spring Web は バックエンドのルーティングなどの機能を提供するライブラリです。そして、Thymeleaf はバックエンドで Web ページをレンダリングするテンプレートエンジンです。

以上で、プロジェクトの作成は完了です。

Web ページを作成する

プロジェクトの作成が完了したら、実際に Web ページの作成に入ります。

まず、バックエンドのルーティングを実装します。プロジェクトの src/main/kotlin ディレクトリの com.example.demo パッケージ配下に GreetingController.kt を追加し、以下のように実装します。

@Controller
class GreetingController {

    @GetMapping("/greeting")
    fun greeting(
        @RequestParam(
            name = "name", required = false, defaultValue = "world"
        ) name: String,
        model: Model
    ): String {
        model.addAttribute("name", name)
        return "index"
    }
}

ルーティングをコントロールするため、追加したクラスに @Controller を付与します。このコントローラクラスには、HTTP リクエストごとにメソッドを追加します。

今回、/greeting の HTTP GET リクエストをコントロールするために、@GetMapping("/greeting") を付与したメソッドを追加します。

このメソッドの引数には、URL クエリパラメータを受け取る name と テンプレートの HTML に値を渡すための model を追加します。URL クエリパラメータは、その名前を "name" とし、省略された場合のデフォルト値は "world" とします。

/greeting の HTTP GET リクエストを受け取ったときには、name の設定値を HTML に渡します(model.addAttribute("name", name))。

最後に、/greeting で表示する HTML のファイル名 "index" を返します。

次に HTML ファイルを実装します。プロジェクトの src/main/resources/templates 配下に、index.html を追加し、以下のように実装します。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Index</title>
  </head>
  <body>
    <p data-th-text="'Hello, ' + ${name} + '!'"></p>
  </body>
</html>

HTML では p タグの data-th-text 属性を評価して、その結果を表示します。${name} にはバックエンドのコントローラで設定された値が付与されます。

プロジェクトを実行して、http://localhost:8080/greeting にアクセスすると、ブラウザに Hello, world! と表示されます。また、例えば http://localhost:8080/greeting?name=tatsuro と URL クエリパラメータを渡すと、ブラウザに Hello, tatsuro! と表示されます。

参考

Thymeleaf Web 画面の作成

【Kotlin】launch ビルダーで Coroutine を実行する

今回は、Kotlin Coroutines の launch ビルダーについて解説します。

なお、ここに掲載しているソースコードは以下の環境で動作確認しています。

  • Android Studio Chipmunk | 2021.2.1 Patch 1
  • JDK 11.0.12
  • Android Gradle Plugin 7.2.1
  • Kotlin 1.7.0
  • Gradle 7.4.2
  • org.jetbrains.kotlinx:kotlinx-coroutines-core 1.6.3

そして、アプリの build.gradle ファイルに kotlinx-coroutines-core をインポートしています。

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3"
}

launch ビルダーとは?

launch ビルダーとは、現在のスレッドをブロックせずに新しい Coroutine を起動し、その起動した Coroutine をハンドリングする Job を返す Coroutine ビルダーです。

例えば、以下のように使います。

fun main() = runBlocking {
    // (1) ノンブロッキングで Coroutine を起動する。
    val job = launch {
        delay(100L)
        // (4) "World!" を標準出力する。
        println("World!")
    }
    // (2) "Hello," を標準出力する。
    println("Hello,")
    // (3) launch ビルダーで起動された Coroutine が完了するまで待つ。
    job.join()
    // (5) "End." を標準出力する。
    println("End.")
}

上記のコードを実行すると、以下の結果を得られます。

Hello,
World!
End.

さきにも述べた通り、launch ビルダーは現在のスレッドをブロックせずに新しい Coroutine を起動します。よって、"World!" を標準出力する Coroutine と "Hello," を標準出力する Coroutine は並列して実行されます。

また、launch ビルダーが返す Job を使って、launch ビルダーにより起動された Coroutine をその Coroutine の外からハンドリングできます。上記のコードでは、Job の join を使って、Coroutine の完了をノンブロッキングで待機しています。