kotlinで勉強がてら遊ぶ(Dockerも少し触る)

2024-02-20に作成

個人的にkotlinを勉強していくのでその作業記録をつけたい

前提

筆者はwebエンジニア歴10年だが内製ばっかやってる弱小
昔php + FuelPHP、今Java + spring boot、DDDをちょっとかじる
最近の新卒の子がElixir使いだったけどナニソレ?関数型プログラミング全然わからん
じゃあ遊んでみるしかないな
kotlinはjavaに似ててオブジェクト指向的にも関数型プログラミング的にも書けるらしいな、とりあえず触ろう
そのうちscala → Elixirみたいに手を広げるゾ!
そして体を壊して休職したのでこれ幸いと手を出す

やりたいこと

  • javaに近いっぽいkotlinで関数型言語を触ってみる
  • ローカルで動く適当なAPI作って見よっか
  • ついでにDockerもちゃんと触り直すか
    • あ、ちょっとまって公式の解説英語の動画なんすか
    • ドキュメント系は公式以外あんま参照したくないんだよなぁ
  • 時間空けると何がなんだかわからないね!どこかに記録残しとこうね!
  • あと前から気になってたcrieitさんを使ってみたかった
所有者限定モードのためこのボードには投稿できません ボードとは?

DBを起動してマルチコンテナにしてみるぞ

PostgreSQLを起動する

    # The commented out section below is an example of how to define a PostgreSQL
    # database that your application can use. `depends_on` tells Docker Compose to
    # start the database before your application. The `db-data` volume persists the
    # database data between container restarts. The `db-password` secret is used
    # to set the database password. You must create `db/password.txt` and add
    # a password of your choosing to it before running `docker compose up`.

と言われているのでせっかくなのでPostgreSQLを使用します。

Learning centerの"Multi-container applications"が参考になるかも。
こちらのサンプルのcompose.yamlを見ます。

どうやらservicesのすぐ下に複数定義するとMulti-containerになるようですね。
サービスとコンテナの言葉の違い的なものがよくわからなかったのでchatGPTに聞いたらこんな感じでした。ほんとかどうか理解するには知識が足りない。

*Dockerにおける「service」とは、コンテナーの実行単位を指します。コンテナーは、アプリケーションやサービスの独立した実行環境を提供するための軽量な仮想化技術です。そして、Docker Composeを使って複数のコンテ ナーをまとめて定義する場合、各コンテナーは1つ以上のサービスとして定義されます。

具体的には、Docker Composeの設定ファイル(通常はdocker-compose.yml)で、services セクションの下に複数のサービスが定義されます。各サービスの定義には、そのサービスに関連するイメージ、ポートの公開、環境変 数の設定などが含まれます。そして、各サービスは1つ以上のコンテナーを起動します。つまり、コンテナーは実際にリソースを提供する実体であり、サービスはそのコンテナーの定義や管理を担当します。

要するに、Dockerにおける「service」とは、論理的なグループ化や管理の単位であり、そのサービスによって実行される1つ以上のコンテナーを指します。一方で、コンテナーは実際にリソースを提供する単位です。*

特に何も考えずにコメントどおりに depends_on の項目をコメントアウト、同様にservice直下のdbの項目もコメントアウトします。

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "app".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  app:
    container_name: tamesikotlin20240122comp # この行を追加してコンテナ名を指定する
    build:
      context: .
    #  target: final
    # If your application exposes a port, uncomment the following lines and change
    # the port numbers as needed. The first number is the host port and the second
    # is the port inside the container.
    ports:
      - 8081:8080
    depends_on:
      db:
        condition: service_healthy

    # The commented out section below is an example of how to define a PostgreSQL
    # database that your application can use. `depends_on` tells Docker Compose to
    # start the database before your application. The `db-data` volume persists the
    # database data between container restarts. The `db-password` secret is used
    # to set the database password. You must create `db/password.txt` and add
    # a password of your choosing to it before running `docker compose up`.
  db:
    image: postgres
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    healthcheck:
      test: [ "CMD", "pg_isready" ]
      interval: 10s
      timeout: 5s
      retries: 5
volumes:
  db-data:
secrets:
  db-password:
    file: db/password.txt

起動確認をします。
今回はバックグラウンド起動を試すために-dを実行します。

$ docker compose up -d --build
Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /host_mnt/Users/********/IdeaProjects/tamesi/db/password.txt
zsh: exit 1     docker compose up -d --build

おっとよく読んでなかった、パスワード指定するためにファイル作れと書いてありますね 作って再実行。

 ✔ Container tamesi-db-1               Healthy
 ✔ Container tamesikotlin20240122comp  Started

起動に成功した様子。接続してみたいと思います。
クライアントはDBeavweを使います。

$ brew install dbeaver-community

docker-compose.yamlの方に設定を加え、ポート5432を公開。

  db:
    image: postgres
    restart: always
    user: postgres
    secrets:
      - db-password
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=example
      - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    expose:
      - 5432
    # ここの下2行を追加
    ports:
      - 5432:5432

起動して接続確認。

$ docker compose up -d --build

OK!

kotlinの関数型言語の描き方がわからない

ところで私はAtomが死んでからメモ帳としてターミナル開いて素のvimを使ってるんですが、最近vim-plugの使い方を忘れてしまい、思い出しがてら新しいcolorschemeにしてみました。
redditで検索しておすすめされてたEverforest、使いやすいです。それまでicebergを使ってたので、VISUALモードが見にくかったんですよね。

閑話休題、関数型的なAPIの書き方の参考になるものを探します。が、なかなか難しいですね。

とりあえず、controllerを実装してる例って無いですね……
なんとなくのイメージで、Router Functionsのような例が出てくると思ったんですが違うようです。
もしかして関数型プログラミングの学習としてkotlinを選んだのは良くなかったのかもしれない。kotlinで学ぶ関数型言語みたいな書籍が無い(英語ならある)時点で察するべきか……
最初からやり直すならHaskell、scala、Elixir、Lispみたいので始めるべきだったかもしれません。

なんとなくで書いてみる

chatGPTさんに聞きつつやってますが、この人結構な確率で嘘をいうのであくまで参考程度に、いろんな資料を斜め読みしつつとりあえずで実装していきます。
なんとなくですが、
- 「関数そのものを変数に代入できる」
- 「数式っぽく扱うためにvoid関数を使わない」
- 「ラムダ式を多用しつつデータを変形させる」
- 「副作用を起こさない」
- 「メソッドチェーンっぽい書き方をする」
らへんなのかなと思います。

副作用云々はDDDでも触れるのでそれっぽい感じでいいんですかね……

Controller

RESTFul APIを目指してこんな感じ

package jp.gooye.toy.tamesi.controller;

import jp.gooye.toy.tamesi.model.TamesiDataResource
import jp.gooye.toy.tamesi.model.TamesiResponse
import jp.gooye.toy.tamesi.service.TamesiDataService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class TamesiController(
    private val service: TamesiDataService
) {

    @GetMapping("/tamesi")
    fun welcome() = TamesiResponse(message = "hello world!!")

    @PostMapping("/save")
    fun create(tamesiDataResource: TamesiDataResource): TamesiDataResource {
        return service.save(tamesiDataResource)
    }

    @GetMapping("/get")
    fun read(@PathVariable id: String): TamesiDataResource {
        // 後で投げる例外は修正する
        return service.findById(id).orElseThrow()
    }

}

Service

package jp.gooye.toy.tamesi.service

import jp.gooye.toy.tamesi.model.TamesiDataResource
import jp.gooye.toy.tamesi.repository.TamesiDataRepository
import org.springframework.stereotype.Service
import java.util.Optional

@Service
class TamesiDataService(
    val repository: TamesiDataRepository,
    val tableDataFactory: TamesiTableDataFactory,
    val resourceFactory: TamesiResourceFactory
) {
    fun findById(id: String): Optional<TamesiDataResource> {
        return repository.findById(id).map { resourceFactory.from(it) }

    }

    fun save(resource: TamesiDataResource): TamesiDataResource {
        return resourceFactory.from(repository.save(tableDataFactory.from(resource)))
    }
}

Factory

package jp.gooye.toy.tamesi.service

import jp.gooye.toy.tamesi.model.TamesiDataResource
import jp.gooye.toy.tamesi.repository.TamesiTableData

class TamesiResourceFactory {
    fun from(data: TamesiTableData): TamesiDataResource {
        return TamesiDataResource(data.name, data.age)
    }
}
package jp.gooye.toy.tamesi.service

import jp.gooye.toy.tamesi.model.TamesiDataResource
import jp.gooye.toy.tamesi.repository.TamesiTableData

class TamesiTableDataFactory {

    fun from(resource: TamesiDataResource): TamesiTableData {
        return TamesiTableData(null, resource.name, resource.age)
    }
}

Repository

package jp.gooye.toy.tamesi.repository

import org.springframework.data.repository.CrudRepository

interface TamesiDataRepository : CrudRepository<TamesiTableData, String>

data

package jp.gooye.toy.tamesi.repository

import jakarta.persistence.Id
import jakarta.persistence.Table

@Table(name = "tamesi")
data class TamesiTableData(
    @Id
    val id: String?,
    val name: String,
    val age: Int
)

とりあえず形にしましたが、これだけでは動きません。次回、DBの接続まわりとユニットテストに手を出していきます。


急遽北海道に一週間行ってたので時間が空いてしまった。悪天候の冬の札幌駅に人生ではじめて降り立ったとき、エルデンリングの例のフォーマットで禁域って文字とドオォォォンみたいなSEが鳴った気がする。

Docker の公式チュートリアルを見つつ進める

といいつつbrewからdokcer desktop無いか確認はする。
dockerのコマンドラインツールはあるけどdocker desktop無いな。公式サイト行きましょ

https://hub.docker.com/

……動画のチュートリアルあるのか。こりゃ便利。英語だけど雰囲気でなんとかなんべ。大体見ます。
スクリーンショット 0006-03-02 16.48.14.png

この前作ったkotlinのapiのルートに移動、以下実行

$ docker init

結果

 ~/IdeaProjects/toykotlin
$ docker init       

Welcome to the Docker Init CLI!

This utility will walk you through creating the following files with sensible defaults for your project:
  - .dockerignore
  - Dockerfile
  - compose.yaml
  - README.Docker.md

Let's get started!

? What application platform does your project use?  [Use arrows to move, type to filter]
  Go - suitable for a Go server application
  Python - suitable for a Python server application
  Node - suitable for a Node server application
  Rust - suitable for a Rust server application
  ASP.NET Core - suitable for an ASP.NET Core application
  PHP with Apache - suitable for a PHP web application
  Java - suitable for a Java application that uses Maven and packages as an uber jar
> Other - general purpose starting point for containerizing your application
  Don't see something you need? Let us know!
  Quit

kotlinなのでOtherにします。Javaともちょっと迷ったけどMavenは使ってないしね。uber jarって何?届けてくれるの?

? What application platform does your project use? Other

CREATED: .dockerignore
CREATED: Dockerfile
CREATED: compose.yaml
CREATED: README.Docker.md

✔ Your Docker files are ready!

Take a moment to review them and tailor them to your application.

When you're ready, start your application by running: docker compose up --build

Consult README.Docker.md for more information about using the generated files.

ファイルができました。中を見ます。
docker desktopのLearning centerでもこのように言っている。
however, that the Dockerfile and compose.yaml file created for your project need additional changes. In this case, you may need to look up the Dockerfile reference⁠ and Compose file reference⁠ in our documentation.

.dockerignore

# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/

**/.DS_Store
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
(後略)

コンテナにコピーしたくないファイルをここに記述しろと書いてあります。大体必要なものは入ってるかな?

compose.yaml

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/

# Here the instructions define your application as a service called "app".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
  app:
    build:
      context: .
      target: final
    # If your application exposes a port, uncomment the following lines and change
    # the port numbers as needed. The first number is the host port and the second
    # is the port inside the container.
    # ports:
    #   - 8080:8080

    # The commented out section below is an example of how to define a PostgreSQL
    # database that your application can use. `depends_on` tells Docker Compose to
    # start the database before your application. The `db-data` volume persists the
    # database data between container restarts. The `db-password` secret is used
    # to set the database password. You must create `db/password.txt` and add
    # a password of your choosing to it before running `docker compose up`.
    #     depends_on:
    #       db:
    #         condition: service_healthy
    #   db:
    #     image: postgres
    #     restart: always
    #     user: postgres
    #     secrets:
    #       - db-password
    #     volumes:
    #       - db-data:/var/lib/postgresql/data
    #     environment:
    #       - POSTGRES_DB=example
    #       - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
    #     expose:
    #       - 5432
    #     healthcheck:
    #       test: [ "CMD", "pg_isready" ]
    #       interval: 10s
    #       timeout: 5s
    #       retries: 5
    # volumes:
    #   db-data:
    # secrets:
    #   db-password:
    #     file: db/password.txt

ここが肝になります。docker 起動するときに必要な設定を全部ここに書いておけば、docker compose up --buildを実行するときに読んでくれます。詳しいことは公式ドキュメント見ろと書いてあります。
一番最初にやることはこれ

services:
  app:
    container_name: tamesikotlin20240122comp # この行を追加してコンテナ名を指定する

コンテナ名指定しないままbuildすると勝手に中二臭い名前にされます。

次、ポート番号

    # If your application exposes a port, uncomment the following lines and change
    # the port numbers as needed. The first number is the host port and the second
    # is the port inside the container.
    ports:
      - 8081:8080

開きたいポートがあるならコメントアウトしろと書いてますね。
前回8080ポート指定でAPI実行したので、今回は8081をフォワーディングしてくれるように設定してみます。
portsの指定場所はservices.app.portsです。字下げの位置に気をつけます。

次。

    # The commented out section below is an example of how to define a PostgreSQL
    # database that your application can use.

来ましたね。PostgreSQLを使ったマルチコンテナ的なやつ。一旦今回はDocker上でKotlin動かすことに集中し、ここは次回に回します。
composeについてはここまで。

Dockerfile

# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/

# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7

################################################################################
# Pick a base image to serve as the foundation for the other build stages in
# this file.
#
# For illustrative purposes, the following FROM command
# is using the alpine image (see https://hub.docker.com/_/alpine).
# By specifying the "latest" tag, it will also use whatever happens to be the
# most recent version of that image when you build your Dockerfile.
# If reproducability is important, consider using a versioned tag
# (e.g., alpine:3.17.2) or SHA (e.g., alpine@sha256:c41ab5c992deb4fe7e5da09f67a8804a46bd0592bfdf0b1847dde0e0889d2bff).
FROM alpine:latest as base

################################################################################
# Create a stage for building/compiling the application.
#
# The following commands will leverage the "base" stage above to generate
# a "hello world" script and make it executable, but for a real application, you
# would issue a RUN command for your application's build process to generate the
# executable. For language-specific examples, take a look at the Dockerfiles in
# the Awesome Compose repository: https://github.com/docker/awesome-compose
FROM base as build
RUN echo -e '#!/bin/sh\n\
echo Hello world from $(whoami)! In order to get your application running in a container, take a look at the comments in the Dockerfile to get started.'\
> /bin/hello.sh
RUN chmod +x /bin/hello.sh

################################################################################
# Create a final stage for running your application.
#
# The following commands copy the output from the "build" stage above and tell
# the container runtime to execute it when the image is run. Ideally this stage
# contains the minimal runtime dependencies for the application as to produce
# the smallest image possible. This often means using a different and smaller
# image than the one used for building the application, but for illustrative
# purposes the "base" image is used here.
FROM base AS final

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    appuser
USER appuser

# Copy the executable from the "build" stage.
COPY --from=build /bin/hello.sh /bin/

# What the container should run when it is started.
ENTRYPOINT [ "/bin/hello.sh" ]

hello worldしかしないDockerfileが置かれています。今回やりたいのはkotlinの起動なのでまるっと書き換えます。

FROM eclipse-temurin:17.0.9_9-jre # java 17でなんか良さそうなやつ
# 適当に作業ディレクトリ
WORKDIR /app
# 作業内容全部コピー
COPY . /app/. 

# buildはあらかじめ実行しておく前提
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java","-jar","/app/app.jar"]

これで実行

$ docker compose up --build
[+] Running 1/0
 ✔ Container tamesikotlin20240122comp  Recreated                                                                                                                                                                                                                      0.1s 
Attaching to tamesikotlin20240122comp
tamesikotlin20240122comp  | 
tamesikotlin20240122comp  |   .   ____          _            __ _ _
tamesikotlin20240122comp  |  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
tamesikotlin20240122comp  | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
tamesikotlin20240122comp  |  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
tamesikotlin20240122comp  |   '  |____| .__|_| |_|_| |_\__, | / / / /
tamesikotlin20240122comp  |  =========|_|==============|___/=/_/_/_/
tamesikotlin20240122comp  |  :: Spring Boot ::                (v3.2.2)
tamesikotlin20240122comp  | 
tamesikotlin20240122comp  | 2024-03-03T15:16:40.531Z  INFO 1 --- [           main] j.gooye.toy.tamesi.TamesiApplicationKt   : Starting TamesiApplicationKt v0.0.1-SNAPSHOT using Java 17.0.9 with PID 1 (/app/app.jar started by root in /app)
tamesikotlin20240122comp  | 2024-03-03T15:16:40.535Z  INFO 1 --- [           main] j.gooye.toy.tamesi.TamesiApplicationKt   : No active profile set, falling back to 1 default profile: "default"
tamesikotlin20240122comp  | 2024-03-03T15:16:41.425Z  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
tamesikotlin20240122comp  | 2024-03-03T15:16:41.434Z  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
tamesikotlin20240122comp  | 2024-03-03T15:16:41.435Z  INFO 1 --- [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.18]
tamesikotlin20240122comp  | 2024-03-03T15:16:41.458Z  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
tamesikotlin20240122comp  | 2024-03-03T15:16:41.459Z  INFO 1 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 853 ms
tamesikotlin20240122comp  | 2024-03-03T15:16:41.761Z  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path ''
tamesikotlin20240122comp  | 2024-03-03T15:16:41.771Z  INFO 1 --- [           main] j.gooye.toy.tamesi.TamesiApplicationKt   : Started TamesiApplicationKt in 1.601 seconds (process running for 1.936)

なんか動いた気がする
じゃあcurlしましょ

$ curl localhost:8081/tamesi
{"message":"hello world!!"}

動きました!
明日はもうちょっといろいろDocker周りをきれいにしたいとおもいます。