Kotlin未経験者が入社から2か月でできた改善・できなかった改善

この記事は株式会社エス・エム・エス Advent Calendar 2024 12月23日の記事です。 qiita.com

はじめに

BPR推進部EA推進Grでエンジニアをしている、尾宇江 @kotaoueです。 苗字が読みづらいので、社内では おうえ で活動しています。
ちなみに、BPR(Business Process Re-engineering)・EA(Enterprise Architecture)で、「部署を横断して会社全体の業務プロセスを改善していこう」みたいな目標のチームとなっています。

この記事では、10月1日からエス・エム・エスに入社し、Kotlin未経験だったおうえが入社から2か月でできた改善・できなかった改善を振り返って紹介します。

改善したシステムの概要

  • 役割 = 介護/障害福祉事業者向け経営支援「カイポケ」の売上情報を計算し全社の会計基盤に統合する
  • 構成と特徴
    • バッチを操作するWebサーバーとWebサーバーから起動されるバッチサーバーの組み合わせ
    • 言語はKotlin
    • ビルドツールはMaven
    • 会計処理を行うため、J-SOX・IT全般統制(Information Technology General Control:ITGC)の監査対象
    • 主な稼働タイミングは月初の数営業日に集中
    • 集計したデータの帳票作成機能もあり
      • 帳票のテンプレートを管理する外部サービスを利用するために、Windows環境も必要

README拡充

貸与されたWindows端末の管理者権限申請が通るまでの約1週間は、JavaやGitなどをインストールすることができなかったために、集中してコードリーディングとREADMEの拡充を実施しました。
後々、最初のコードリーディングが効いてくることが多く、Goodなアクションだったなと自画自賛しています。
ちなみに、GitHub Desktopは使えたので、PR作成などは可能でした。

GitHub Branch protection rules の設定

うっかりミスが怖いなということで、最初のPRを作成する前に未レビューだとマージできないように「Require a pull request before merging」のブランチ保護ルールを設定しました。
GitHubの権限さえあればすぐにできますし、心理的な安心感もかなり高いのでおすすめです。
ちなみに、同時に「Automatically delete head branches 」の設定も追加して、PR取り込んだらブランチ削除するようにしました。

GitHub Actionsでのテスト実行

まずはシンプルにmvn verify を実行するGitHub Actionsを用意しました。
この時点ではテストコードは0行ですが、 verifyすればコンパイルが行われ文法エラーなど最低限のチェックになるので、簡単な追加の割に満足度が高かったです。

name: Maven Test

on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
      - name: Check out the repository
        uses: actions/checkout@v3

      - name: Set up JDK 11
        uses: actions/setup-java@v3
        with:
          distribution: "temurin"
          java-version: "11"
          
      - name: Run integrations-tests
        run:
          mvn verify

octocov の追加

地道にテストコードを追加するときに、テンションを高く保てるように、最優先でカバレッジを見える化することにしました。
octocovJaCoCoを組み合わせることでPRに↓のようにレポートしてくれます。

設定は

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>${jacoco.version}</version>
    <executions>
        <!-- ユニットテストの前に JaCoCo エージェントをアタッチ -->
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <!-- ユニットテストのカバレッジレポートを生成 -->
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>post-unit-test</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <!-- 統合テストの前にも JaCoCo エージェントをアタッチ -->
        <execution>
            <id>pre-integration-test</id>
            <phase>pre-integration-test</phase>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <!-- 統合テストのカバレッジレポートを生成 -->
        <execution>
            <id>do-repport-on-post-integrations</id>
            <phase>post-integration-test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <!-- カバレッジデータを集約して最終レポートを生成 -->
        <execution>
            <id>report-aggregate</id>
            <phase>verify</phase>
            <goals>
                <goal>report</goal>
            </goals>
            <configuration>
                <dataFile>${project.build.directory}/jacoco.exec</dataFile>
                <outputDirectory>${project.reporting.outputDirectory}/jacoco-aggregate</outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

でJaCoCoをpom.xmlに組み込んだあと

name: Maven Test

on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      docker:
        image: docker:20.10.16
        options: --privileged

    steps:
      〜 中略 〜
      - name: Run octocov
        uses: k1LoW/octocov-action@v1.4.0

      - name: List files coverage
        run: octocov ls-files | sort -k1,1n

な形でActionsに組み込みました。
最初のRun octocovだけでレポートはできるのですが、クラスごとのカバレッジも見えるとテンションが高まるのでoctocov ls-filesも実行しています。
※ ソート機能がなかったので、sortを使っていますが、55.5% のような文字列でソートしているので 1%, 10%, 5% のような 並び順がおかしくなるのは一旦無視しました。
さらに詳細な行単位のカバレッジレポートはローカルでmvn verifyを実行したあと、./target/site/jacoco-aggregate/index.html に出力されます。

ちなみに、12月初旬でのカバレッジは の25.9%になりました!

テストコード追加

↑の修正でGitにPushしたらテストが回る状態を実現できたので、あとは地道にテストを増加させていくことにしました。
ちなみに当初の予定では主要な機能のテスト作成 → KotlinやJavaのアップグレードという流れをイメージしていましたが、後述のMockKの問題によりテストコード追加とアップグレードをパラレルで実行することになりました。

MockKの導入

外部システムとのやりとりがあったり、細かいところには目をつぶって主要な機能のテストを作りたい、というような方針からMockしたいケースが発生することが予想できたので、まずはMockKを導入することにしました。
ただ、MockKはKotlin 1.5.* 以降でないとかなり制限がかかってしまうことが判明したために、カバレッジ3%くらいの状態でいきなりKotlinを1.5.* までアップグレードすることになりました。

この時点では主要な機能は

fun testBatchExecute() {
    mockkObject(Batch)
    every { Batch.fetchData() } returns Unit
    every { Batch.processData() } returns Unit
    every { Batch.saveData() } returns Unit

    Batch.execute()
    
    verify(exactly = 1) { Batch.fetchData() }
    verify(exactly = 1) { Batch.processData() }
    verify(exactly = 1) { Batch.saveData() }
}

のような表層的なテストになる場合もありましたが、まずはここからという感じで準備しました。

H2 Database Engineの導入

データベースのテストにはH2 Database Engineを利用することにしました。
当初はテスト時はローカルのデータベースに接続するのは少し修正箇所が多く難しかったために、インメモリのH2 Database Engineを採用しました。

設定は簡単で

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <!-- Setting for H2DB on test -->
        <systemPropertyVariables>
            <db.url>jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE</db.url>
            <db.username>sa</db.username>
            <db.password></db.password>
            <db.driverClassName>org.h2.Driver</db.driverClassName>
        </systemPropertyVariables>
    </configuration>
</plugin>
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>setup-db</id>
            <phase>pre-integration-test</phase>
            <goals>
                <goal>java</goal>
            </goals>
            <configuration>
                <mainClass>org.h2.tools.RunScript</mainClass>
                <classpathScope>test</classpathScope>
                <arguments>
                    <argument>-url</argument>
                    <argument>
                        jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE</argument>
                    <argument>-user</argument>
                    <argument>sa</argument>
                    <argument>-password</argument>
                    <argument></argument>
                    <argument>-script</argument>
                    <argument>src/test/resources/schema.sql</argument>
                </arguments>
            </configuration>
        </execution>
    </executions>
</plugin>

のように設定するとschema.sqlに記述したCREATEなどが実行されてテストで利用できるようになります。

ただ、複雑なSQLを書いているケースは皆無だったのと、取得したデータをObjectで取り扱っているためにmockkObjectでMockしてしまえば事足りるケースが大半でした。
DBに投入したデータについては、ちゃんと後始末しないと別クラスに影響与えることも多いために、利用するかどうかはケースバイケースだなと改めて思いました。

LocalStackの導入

S3を利用していたのでLocalStackを導入しました。
ローカル環境で利用するのはTestcontainersを使えばかなり簡単で

import com.amazonaws.auth.AWSStaticCredentialsProvider
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.client.builder.AwsClientBuilder
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName

open class AbstractLocalStackTest {
    companion object {
        private lateinit var localStack: LocalStackContainer
        lateinit var s3Client: AmazonS3
        var bucketName = "test-bucket"

        @JvmStatic
        @BeforeAll
        fun setUpBeforeAll() {
            localStack =
                LocalStackContainer(DockerImageName.parse("localstack/localstack:latest"))
                    .withServices(LocalStackContainer.Service.S3)
            localStack.start()

            val endpoint = localStack.getEndpointOverride(LocalStackContainer.Service.S3).toString()
            val credentials = AWSStaticCredentialsProvider(BasicAWSCredentials(localStack.accessKey, localStack.secretKey))

            s3Client =
                AmazonS3ClientBuilder
                    .standard()
                    .withEndpointConfiguration(AwsClientBuilder.EndpointConfiguration(endpoint, localStack.region))
                    .withCredentials(credentials)
                    .withPathStyleAccessEnabled(true)
                    .build()

            if (!s3Client.doesBucketExistV2(bucketName)) {
                s3Client.createBucket(bucketName)
            }

            S3Service.setS3Client(s3Client)
            ErpS3Service.setS3Client(s3Client)
        }

        @JvmStatic
        @AfterAll
        fun tearDownAfterAll() {
            localStack.stop()
        }
    }

な感じで設定すれば、あとは通常のS3に接続するようにテストできます。

ただ、Actionsで動かすのはコンテナ内でコンテナを動かすことになるので、↓の感じで少しだけ調整する必要があります。

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      docker:
        image: docker:20.10.16
        options: --privileged
      〜 中略 〜
      - name: Run integrations-tests
        run: mvn verify
        env:
          TESTCONTAINERS_HOST_OVERRIDE: localhost
          DOCKER_HOST: tcp://localhost:2375

linterの導入

Kotlinに慣れていないこともあって、お作法的な書き方がわからなかったので、Ktlintを導入しました。
ちなみに、Kotlinを1.6.*までアップグレードしないとうまく動作しなかったです。

修正は2回のPRに分けて な感じで対応しました。

リポジトリのルートに.editorconfigというファイルを配置することでlintのルールを指定することができるために、「全部のルールを除外する」→「一つだけルールを許可する」→「ktlint:formatで自動ルール適用」→「commit」のようにしていけば、似たような変更だけでcommitをまとめることができるので、レビュワーも楽な気がします。

ちなみにktlint:formatで解消できないものは手動で解消する必要があります。

[*.{kt,kts}]
ktlint_standard_backing-property = disabled
〜 中略 〜
ktlint_standard_body-expression = disabled

max_line_length = 200

コンテナ化

システムはLinuxスタイルパスで設計されているが、開発環境はWindowsで開発しづらかったために、コンテナ化しました。
副産物としては「コンテナで動作させる ≒ 新しい環境を用意する」ということで、インフラからみたシステム理解がかなり進みました。
※ ちなみにその後Macも貸与されて、WindowsとMacの2台持ちになりました。

Kotlinのアップグレード 1.3. -> 2.1.

最終的には2.1. までアップしました。
といいつつ、MockKが使えなかった1.5.
未満、ktlintが使えなかった1.6.未満と違い1.7. -> 2.1.* はpomの数値を変更するだけでアップグレードできました。

Javaのアップグレード 11 -> 17

こちらも何も問題なくアップグレードできました。
ただ、Kotlinと違いサーバーにインストールされているJavaのVerも関連するのでリリースするタイミングには少し注意が必要でした。

pom.xml の最新化

ある程度テストも増加したので、mvn versions:use-latest-release で pom.xml  の各種プラグインをアップグレードしました。

Renovate

pomもアップグレードできたので、大手を振ってRenovateを導入しました。
ちなみにJ-SOXの都合で承認フローを通っていない改修を本番リリースすることはNGなので、自動マージはOFFで運用しています。

構造化ログの導入

Logbackを導入してログをJsonフォーマットに変更しました。
呼び出し元もログに出力したかったので、src/main/resources/logback.xml に以下のような設定を追加しています。
ちなみに、 で指定すると、特定のパッケージのログレベルを変更することができます。
nettyがかなりのDEBUGログを出力したので、個別OFFにしています。

<configuration>
    <property name="LOG_LEVEL" value="${LOG_LEVEL:-debug}" />

    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeCallerData>true</includeCallerData>
        </encoder>
    </appender>

    <logger name="io.netty" level="INFO" />
    <root level="${LOG_LEVEL}">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

また、

import ch.qos.logback.classic.Level
import ch.qos.logback.classic.LoggerContext
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.AppenderBase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory

class LoggerTest {
    private class TestAppender : AppenderBase<ILoggingEvent>() {
        var lastMessage: String? = null
        var lastLevel: Level? = null

        override fun append(eventObject: ILoggingEvent) {
            lastMessage = eventObject.formattedMessage
            lastLevel = eventObject.level
        }
    }

    private var appender: TestAppender = TestAppender()
    private var originalLevel: Level = Level.ALL

    @BeforeEach
    fun setUp() {
        val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext
        val logger = loggerContext.getLogger("TestLogger")

        originalLevel = logger.level
        logger.level = Level.ALL

        appender.start()
        logger.addAppender(appender)
    }

    @AfterEach
    fun tearDown() {
        val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext
        val logger = loggerContext.getLogger("TestLogger")

        logger.level = originalLevel

        logger.detachAppender(appender)
        appender.stop()
    }

    @Test
    fun testDebug() {
        Logger.debug("Debug message")
        assertEquals("Debug message", appender.lastMessage)
        assertEquals(Level.DEBUG, appender.lastLevel)
    }
}

のような感じでログメッセージをテストすることができるので、テストも書きやすいです。

プロジェクト管理まわりでできた改善

開発サーバー・DBのスケジュール起動

開発用のサーバーやDBが常時起動していたので、EventBridgeで営業日の定時前に起動、定時後に終了といったスケジュールを用意しました。

J-SOX, ITGC関連の整備

会計情報を扱っているというシステムの特性として絶対に必要なITGCですが、運用が煩雑で開発フローと相性の良くないポイントも発生しておりました。
一例としては「PRをマージする際には必ず上長がレビュー→Approve→Mergeすること」のような運用があり、上長に負荷が集中していました。
このあたりの運用については監査法人の方と相談し、監査内容としても十分で開発フローとしても制約にならない運用に変更できました。
※ これがこの記事のなかで一番のWinかもです。

レビュー体制の改善

これまでチーム内部でしかレビューを実施していなかったのですが、別チームのメンバーにもレビュー依頼を出すことにしました。 新たな視点でのレビューコメントをもらえることも利点ですが、「このPRはドメイン知識がかなり必要だからしっかりと説明しなきゃ」のようにチーム内部だと徐々にハイコンテクストになりがちだったコミュニケーションを整理して、新メンバーの参画時の準備としても有用に働いているのも利点に感じています。

SaaSのバックアップとBCP検討

帳票などで複数の外部サービスを利用しているために、各種サービスの設定をどのようにバックアップしておき、万が一の場合に復旧するのかといったBCPを検討しました。

ちなみにサービスのSLAを単体で見るとまず障害なんて発生しないように感じますが、複数のサービスのSLAをかけ合わせていくと、結構な頻度で何らかの障害が発生するということが目に見えてかなりドキドキしました。

できなかった改善

以下、やりたかったけど間に合わなかった改善についての簡単なメモです。

  • CI/CDパイプラインの構築 現状はActionsでテストしていますが、Deployまでは実施できてないです。 といいつつ、↓のmigrationツールの導入など前段の準備が必要なので、段階的に改善していこうと思っています。
  • migrationツールの導入 手動オペレーションを脱却することで、CI/CDへの頂きを目指したいです。
  • EC2脱却 EC2を起動してバッチを実行しており、速度とコストに改善余地があるので、Fargate や ECS on EC2 などに載せ替えようと考えています。
  • SCPのテスト 実はSCPでやりとりしている箇所があるのでテストを書きたいなと思っています。 TestcontainersでSCPサーバーを立てて通信しようかなーといったアイデアだけある状態です。
  • Salesforceのテスト  結構複雑なSalesforce Object Query Language (SOQL)を書いている部分もあり、レスポンスをMockするのではなくてSOQLもテストしたいなと思っているものの、あまり良い方法がなさそうで方針を検討中です。
  • J-SOX対応の自動化 監査用の提出資料の準備ですが、結構な工数がかかる上に、定期的に実施する作業なので、なんらか自動化して、より通常業務に集中できるチーム体制にしたいなと思っております。

まとめ

といった感じで、Kotlin未経験者が入社から2か月でできた改善・できなかった改善のまとめとなります。
チームメンバー数が少なかったり、自由に行動させてもらえるという裁量の高さもあって、個人のやりやすい形で進行させてもらえました。

一つ一つは細かい改善ですが、1ヶ月2ヶ月と積み重ねていくとちょっとした量の改善になっていて満足度も高いので、気軽に改善する文化を広めていきたいなと思っております。
またテストコードの追加など地盤固め・守りの改善をしていくことで、新機能を追加する際に「対抗先システムに例外パターンも網羅したテストデータを用意しなきゃテストできないが、そのためにはパターンごとにデータ洗替が必要でとても大変…」といった状態から「自動テストで確認できてるのでコア部分に注力してテスト可能」といった効率的な状態に移行し開発スピードにも貢献できると思っております。

といった感じで、来年も「情熱」「誠実」を持って社会に貢献していけるよう開発・改善を進めていきます!