首页 > 解决方案 > 从 groovy 迁移到 kotlin gradle 脚本后单元测试中出现 NullPointerException

问题描述

我不知道这里发生了什么,不应该有联系。无论如何,没有一个具体体现这一点。

在将主要 spring-boot 项目的构建脚本从 groovy 移动到 kotlin-DSL 之后,一些单元测试抛出 NullPointerException。处理使用 TransactionTemplate 进行手动事务管理的方法的所有单元测试。这在搬家之前没有发生过,现在我已经比较了新旧的 buildscript 十几次,并且非常有信心我没有忘记任何事情。

让我们首先比较两个版本的构建脚本。

老套路:

buildscript {
    ext {
        kotlinVersion = '1.4.10'
        springBootVersion = '2.3.3.RELEASE'
        awsSdkVersion = '1.11.381'
        springfoxVersion = '2.9.2'
        kotlintestVersion = '3.1.7'
    }
    repositories {
        mavenLocal()
        jcenter()
        mavenCentral()
    }
    dependencies {
        classpath "org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
        classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion"
        classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlinVersion"

        // liquibase stuff
        classpath 'mysql:mysql-connector-java:5.1.44'
        classpath 'org.yaml:snakeyaml:1.19'
        classpath('org.liquibase:liquibase-gradle-plugin:1.2.3') {
            exclude group: "org.liquibase", module: "liquibase-core"
        }
        classpath "org.liquibase:liquibase-core:3.5.3" // should be in sync with the version included in spring boot
    }
}

plugins {
    id 'net.researchgate.release' version '2.6.0'
}

apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'kotlin-jpa'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'jacoco'
apply plugin: 'org.liquibase.gradle'
apply plugin: 'idea'

group = 'webcam.yellow.service'
sourceCompatibility = 1.8

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

jacocoTestReport {
    reports {
        xml.enabled = true
        html.enabled = true
    }
}

check.dependsOn jacocoTestReport

repositories {
    mavenLocal()
    mavenCentral()
    jcenter()
    maven { url = "http://clojars.org/repo/" }
    maven {
        credentials {
            username "${mavenUser}"
            password "${mavenPassword}"
        }
        url = "https://artifactory.yellow.webcam/artifactory/releases"
    }

    maven {
        credentials {
            username "${mavenUser}"
            password "${mavenPassword}"
        }
        url = "https://artifactory.yellow.webcam/artifactory/snapshots"
    }

}

idea {
    module {
        // if you hate browsing Javadoc
        downloadJavadoc = false
        // and love reading sources :)
        downloadSources = true
    }
}

dependencies {
    // avisec libraries
    compile "webcam.yellow.api:webcam-api:3.2.0"
    compile "webcam.yellow.authentication:messaging-authentication:1.5"

    // Spring Boot
    compile 'org.springframework.boot:spring-boot-starter-actuator'
    compile 'org.springframework.boot:spring-boot-starter-data-jpa'
    compile 'org.springframework.boot:spring-boot-starter-web'
    compile 'org.springframework.boot:spring-boot-starter-security'
    compile 'org.springframework.boot:spring-boot-starter-activemq'
    compile 'org.springframework.boot:spring-boot-starter-validation'

    compile 'io.micrometer:micrometer-registry-influx'

    compile 'com.fasterxml.jackson.module:jackson-module-kotlin'
    compile 'com.fasterxml.jackson.datatype:jackson-datatype-joda'

    // Joda
    compile 'joda-time:joda-time'
    compile 'org.jadira.usertype:usertype.core:6.0.1.GA'

    // Libraries
    compile group: 'org.apache.commons', name: 'commons-text', version: '1.1'
    compile group: 'org.apache.commons', name: 'commons-csv', version: '1.8'

    // Kotlin
    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}"
    compile "org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}"

    // Mail
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '2.2.4.RELEASE'
    compile group: 'org.apache.commons', name: 'commons-email', version: '1.5'
    compile group: 'javax.mail', name: 'mail', version: '1.5.0-b01'

    // AWS
    compile "com.amazonaws:aws-java-sdk-s3:$awsSdkVersion"
    compile group: 'com.amazonaws', name: 'aws-java-sdk-sqs', version: '1.11.558'
    compile group: 'software.amazon.awssdk', name: 's3', version: '2.10.61'
    compile group: 'com.amazonaws', name: 'aws-java-sdk-ec2', version: '1.11.740'

    // Support old pw hash lib
    compile group: 'buddy', name: 'buddy-hashers', version: '1.3.0'

    // Hazelcast
    compile group: 'com.hazelcast', name: 'hazelcast-spring'
    compile group: 'com.hazelcast', name: 'hazelcast-aws', version: '2.4'

    // Swagger
    compile group: 'io.springfox', name: 'springfox-swagger2', version: springfoxVersion
    compile group: 'io.springfox', name: 'springfox-swagger-ui', version: springfoxVersion

    // Database
    runtime 'mysql:mysql-connector-java'
    runtime 'org.liquibase:liquibase-core'
    compile 'org.influxdb:influxdb-java:2.13'

    // HTML Generation
    compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-html-jvm', version: '0.6.10'
    //  ActiveMQ
    compile group: 'org.messaginghub', name: 'pooled-jms'

    // Development
    compile "org.springframework.boot:spring-boot-devtools"

    // Testing
    testCompile 'org.springframework.boot:spring-boot-starter-test'
    testCompile 'org.springframework.security:spring-security-test'
    testCompile 'com.h2database:h2'
    testCompile 'io.kotlintest:kotlintest:2.0.7'
    testCompile 'com.nhaarman:mockito-kotlin:1.6.0'


    // GSON
    compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6'
}

liquibase {
    activities {
        main {
            changeLogFile '/db/changelog/db.changelog-master.yaml'
            url 'jdbc:mysql://localhost:3307/yellow'
            username 'user'
            password 'secret'
            classpath 'src/main/resources'
        }
    }
}


springBoot {
    buildInfo()
}

task ebextensions(type: Exec) {
    executable "sh"
    args "-c", "jar uf build/libs/webcam-service*.jar .ebextensions"
}

bootJar.finalizedBy ebextensions

afterReleaseBuild.dependsOn bootJar

test {
    minHeapSize = "1024m"
    maxHeapSize = "1024m"
    jvmArgs = ["-Xloggc:build/gclog-%p.log", "-XX:+PrintGCDetails"]
}

和新的 kotlin 之一:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

buildscript {
    dependencies {
        classpath("org.jfrog.buildinfo:build-info-extractor-gradle:4.9.6")
        classpath("org.jetbrains.kotlin:kotlin-allopen:1.4.10")
        // liquibase stuff
        classpath("mysql:mysql-connector-java:5.1.44")
        classpath("org.yaml:snakeyaml:1.19")
        classpath("org.liquibase:liquibase-gradle-plugin:1.2.3") {
            exclude("org.liquibase", "liquibase-core")
        }
        classpath("org.liquibase:liquibase-core:3.8.9") // should be in sync with the version included in spring boot
    }
}

val awsSdkVersion = "1.11.740"
val springfoxVersion = "2.9.2"


plugins {
    id("org.springframework.boot") version "2.3.3.RELEASE"
    id("io.spring.dependency-management") version "1.0.9.RELEASE"
    id("net.researchgate.release") version "2.6.0"
    kotlin("jvm") version "1.4.10"
    kotlin("plugin.spring") version "1.4.10"
    `maven-publish`
    id("com.jfrog.artifactory") version "4.14.1"
    id("org.liquibase.gradle") version "2.0.4"
    id("org.jetbrains.kotlin.plugin.noarg") version "1.4.10"
    id("org.jetbrains.kotlin.plugin.jpa") version "1.4.10"
}

group = "webcam.yellow.service"
java.sourceCompatibility = JavaVersion.VERSION_1_8

val mavenUser: String by project
val mavenPassword: String by project
val artifactoryRepository = System.getenv("ARTIFACTORY_REPO") ?: "snapshots"


repositories {
    mavenCentral()
    jcenter()
    maven {
        credentials {
            username = mavenUser
            password = mavenPassword
        }
        url = uri("https://artifactory.yellow.webcam/artifactory/releases")
    }

    maven {
        credentials {
            username = mavenUser
            password = mavenPassword
        }
        url = uri("https://artifactory.yellow.webcam/artifactory/snapshots")
    }
}

dependencies {

    // Spring Boot
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-activemq")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-mail")


    implementation("io.micrometer:micrometer-registry-influx")

    // Kotlin
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.6.8")

    // Yellow Dependencies
    implementation("webcam.yellow.api:webcam-api:3.2.0")
    implementation("webcam.yellow.authentication:messaging-authentication:1.5")

    // AWS
    implementation("com.amazonaws:aws-java-sdk-s3:$awsSdkVersion")
    implementation("software.amazon.awssdk:s3:2.10.61") // does not yet incorporate all functionality of 1.11.X
    implementation("com.amazonaws:aws-java-sdk-sqs:$awsSdkVersion")
    implementation("com.amazonaws:aws-java-sdk-ec2:$awsSdkVersion")

    // Support old pw hash lib
    implementation("buddy:buddy-hashers:1.3.0")

    // Jackson
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-joda")

    // Hazelcast
    implementation("com.hazelcast:hazelcast-spring")
    implementation("com.hazelcast:hazelcast-aws:2.4")

    // Other Libs
    implementation("org.apache.commons:commons-text:1.1")
    implementation("org.apache.commons:commons-csv:1.8")

    implementation("org.messaginghub:pooled-jms")
    implementation("org.influxdb:influxdb-java:2.13")
    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.6.10")
    implementation("org.springframework.boot:spring-boot-devtools")
    implementation("com.google.code.gson:gson:2.8.6")
    implementation("joda-time:joda-time")
    implementation("org.jadira.usertype:usertype.core:6.0.1.GA")


    // Swagger
    implementation("io.springfox:springfox-swagger2:$springfoxVersion")
    implementation("io.springfox:springfox-swagger-ui:$springfoxVersion")

    // Database
    runtimeOnly("mysql:mysql-connector-java")
    runtimeOnly("org.liquibase:liquibase-core")
    implementation("org.influxdb:influxdb-java:2.13")


    // Test
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
    testImplementation("com.h2database:h2")
    testImplementation("com.nhaarman:mockito-kotlin:1.6.0")
    testImplementation("io.kotlintest:kotlintest:2.0.7")

}

tasks.withType<Test> {
    useJUnitPlatform()
}

springBoot {
    buildInfo()
}

liquibase {
    activities.register("main") {
        arguments = mapOf(
            "changeLogFile" to "/db/changelog/db.changelog-master.yaml",
            "url" to "jdbc:mysql://localhost:3307/yellow",
            "username" to "user",
            "password" to "secret",
            "classpath" to "src/main/resources"
        )
    }
}


tasks {
    withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict")
            jvmTarget = "1.8"
        }
    }

    bootJar {
        from("./.ebextensions") { into(".ebextensions") }
        launchScript()
    }

    afterReleaseBuild {
        dependsOn(bootJar)
    }

    test {
        minHeapSize = "1024m"
        maxHeapSize = "1024m"
        jvmArgs = listOf("-Xloggc:build/gclog-%p.log", "-XX:+PrintGCDetails")
    }
}

有些事情需要不同的处理,比如将一些插件从类路径移动到插件部分,.ebsextensions 的处理方式不同,Jacoco 不再存在,因为我们从未真正在 jenkins 中使用过这些报告。总而言之,很正常。一切正常,除了单元测试中的这一件事。

这是一个类初始化和一个经过测试的方法:

@Service
class ImageService(private val imageRepository: ImageRepository,
                   private val imageSetRepository: ImageSetRepository,
                   private val permissionService: PermissionService,
                   private val s3Service: S3Service,
                   private val entityManager: EntityManager,
                   private val panoFeedRepository: PanoFeedRepository,
                   private val panoImageRepository: PanoImageRepository,
                   private val jobFacade: JobFacade,
                   private val jdbcTemplate: NamedParameterJdbcTemplate,
                   transactionManager: PlatformTransactionManager,
                   @Value("\${service.cleanup.enabled}")
                   private val cleanupEnabled: Boolean) {


    private val readTransaction = org.springframework.transaction.support.TransactionTemplate(
            transactionManager,
            DefaultTransactionDefinition().apply {
                isReadOnly = true
                propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
            })
    private val writeTransaction = org.springframework.transaction.support.TransactionTemplate(
            transactionManager,
            DefaultTransactionDefinition().apply {
                isReadOnly = false
                propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
            })


    /**
     * Deletes all passed images from the database while removing them from S3 asynchronously.
     * Does not check any permissions.
     * This opens its own write transaction, which could take a long time. If individual transactions take too long,
     * invoke this method multiple times with smaller chunks to avoid issues with table locking.
     * Since this is potentially long-running, commit preceding transactions beforehand.
     */
    @Transactional(propagation = Propagation.NEVER)
    internal fun delete(images: List<ImageIdAndImageKeyAndImagePreviewKey>) {
        images.chunked(500).forEach { chunk ->
            log.debug("Deleting s3 files of {} images", chunk.size)
            jobFacade.executeAsync {
                val keysToDelete = chunk
                        .flatMap { listOf(it.getImageKey(), it.getImagePreviewKey()) }
                        .asSequence()
                        .filterNotNull()
                        .filter { !it.isBlank() }
                        .toList()


                s3Service.deleteImages(keysToDelete)
                log.trace("deleted {} s3 files of {} images", keysToDelete.size, chunk.size)
            }
            log.debug("Deleting {} images from database", chunk.size)
            writeTransaction.execute {
                imageRepository.deleteAllByIdIn(chunk.map { it.getId()!! })
            }   // <---- Exception happens in here
            log.trace("Deleted {} images from database", chunk.size)
        }
    }
}

这是一个测试设置和一个测试 tat 方法的测试:

class ImageServiceTest {

    private val imageRepository: ImageRepository = mock()
    private val imageSetRepository: ImageSetRepository = mock()
    private val permissionService: PermissionService = mock()
    private val s3Service: S3Service = mock()
    private val hazelCastInstance: HazelcastInstance = mock()
    private val panoFeedRepository: PanoFeedRepository = mock()
    private val panoImageRepository: PanoImageRepository = mock()
    private val jdbcTemplate: NamedParameterJdbcTemplate = mock()
    private val transactionManager: PlatformTransactionManager = mock()
    private val jobFacade: JobFacade

    private val sut: ImageService

    init {
        val imap: IMap<String, String> = mock()
        `when`(hazelCastInstance.getMap<String, String>(any())).thenReturn(imap)
        jobFacade = JobFacade(hazelCastInstance)
        sut = ImageService(
                imageRepository, imageSetRepository, permissionService, s3Service, mock(),
                panoFeedRepository, panoImageRepository, jobFacade, jdbcTemplate, transactionManager, false)
    }

    @Test
    fun `deleting multiple images invokes S3Service in chunks and deletes images from DB`() {
        val user = TestModelFactory.userWithOneCameraAndTablePermission()
        val table = user.tablePermissions.first().table!!
        val images = (1..800).map { TestModelFactory.image(table = table) }

        sut.delete(images.map { ImageIdentifiers(it.id, it.imageKey, it.imagePreviewKey) })
        Thread.sleep(2000) //leave enough time for S3 uploads to finish

        val idChunks = images.map { it.id }.chunked(500)
        verify(imageRepository, times(1)).deleteAllByIdIn(eq(idChunks[0]))
        verify(imageRepository, times(1)).deleteAllByIdIn(eq(idChunks[1]))
        verify(s3Service, times(2)).deleteImages(any())
    }

最后,错误:

java.lang.NullPointerException: Parameter specified as non-null is null: method webcam.yellow.service.service.ImageService$delete$$inlined$forEach$lambda$2.doInTransaction, parameter it

现在,我目前正在研究可能出错的几件事,涉及模拟的 TransactionManager 和非模拟的 TransactionTemplates ......但真正令人沮丧的是,这在迁移构建脚本之前根本没有发生,并且没有其他改变。

标签: spring-bootkotlingradle

解决方案


事实证明,transactionTemplate(未模拟)调用了 platformTransactionManager 模拟上的方法,该方法返回 null(显然),并导致 NullPointerException。

通过将模拟实例配置为返回值来相对容易地修复它:

given(transactionManager.getTransaction(any())).willReturn(mock())

知道为什么这曾经有效。这个问题应该一直都有,但不是……只有在我迁移构建文件时它才显露出来,这并没有多大意义。


推荐阅读