company_banner

Continuous delivery для вашей Kotlin Multiplatform библиотеки

    Logo


    Привет! Меня зовут Юрий Влад, я Android-разработчик в компании Badoo и принимаю участие в создании библиотеки Reaktive — Reactive Extensions на чистом Kotlin.


    В процессе работы мы столкнулись с тем, что в случае с Kotlin Multiplatform continuous integration и continuous delivery требуют дополнительной настройки. Необходимо иметь в распоряжении несколько виртуальных машин на различных операционных системах, чтобы собрать библиотеку полностью. В этой статье я покажу, как настроить continuous delivery для вашей Kotlin Multiplatform библиотеки.


    Continuous integration и continuous delivery для open-source библиотек


    Continuous integration и continuous delivery уже давно стали частью open-source комьюнити благодаря различным сервисам. Многие из них предоставляют свои услуги open-source проектам совершенно бесплатно: Travis CI, JitPack, CircleCI, Microsoft Azure Pipelines, недавно запущенный GitHub Actions.


    В open-source проектах Badoo для Android мы используем Travis CI для continuous integration и JitPack — для continuous delivery.


    После реализации поддержки iOS в нашей мультиплатформенной библиотеке я обнаружил, что мы не можем собирать библиотеку с помощью JitPack, поскольку он не предоставляет виртуальные машины на macOS (iOS можно собирать только на macOS).


    Поэтому для дальнейшей публикации библиотеки был выбран более привычный всем Bintray. Он поддерживает более тонкую настройку публикуемых артефактов, в отличие от JitPack, который просто забирал все результаты вызова publishToMavenLocal.


    Для публикации рекомендуется использовать Gradle Bintray Plugin, который я впоследствии и настроил под наши нужды. А для сборки проекта я продолжил использовать Travis CI по нескольким причинам: во-первых, я уже был знаком с ним и использовал его практически во всех своих pet-проектах; во-вторых, он предоставляет виртуальные машины на macOS, необходимые для сборки под iOS.


    Параллельная сборка мультиплатформенной библиотеки


    Если покопаться в недрах документации Kotlin, то можно обнаружить раздел про публикацию мультиплатформенных библиотек.


    Разработчики Kotlin Multiplatform осознают проблемы мультиплатформенной сборки (не всё можно собрать на какой-либо операционной системе) и предлагают собирать библиотеку по отдельности на разных ОС.


    kotlin {
        jvm()
        js()
        mingwX64()
        linuxX64()
    
        // Note that the Kotlin metadata is here, too. 
        // The mingwx64() target is automatically skipped as incompatible in Linux builds.
        configure([targets["metadata"], jvm(), js()]) {
            mavenPublication { targetPublication ->
                tasks.withType(AbstractPublishToMaven)
                    .matching { it.publication == targetPublication }
                    .all { onlyIf { findProperty("isLinux") == "true" } }            
            }
        }
    }

    Как видно из кода, приведённого выше, в зависимости от переданного в Gradle свойства isLinux мы включаем публикацию тех или иных таргетов. Под таргетами в дальнейшем я буду иметь ввиду сборку под конкретную платформу. На Windows будет собираться только Windows-таргет, а на других ОС будут собираться метаданные и другие таргеты.


    Очень красивое и лаконичное решение, работающее только для publishToMavenLocal или publish от плагина maven-publish, который нам не подходит из-за использования Gradle Bintray Plugin.


    Я решил использовать environment variable для выбора таргета, так как этот код раньше был написан на Groovy, лежал в отдельном скрипте Groovy Gradle и доступ к переменным окружения есть из статического контекста.


    enum class Target {
        ALL,
        COMMON,
        IOS,
        META;
    
        val common: Boolean
            @JvmName("isCommon")
            get() = this == ALL || this == COMMON
    
        val ios: Boolean
            @JvmName("isIos")
            get() = this == ALL || this == IOS
    
        val meta: Boolean
            @JvmName("isMeta")
            get() = this == ALL || this == META
    
        companion object {
    
            @JvmStatic
            fun currentTarget(): Target {
                val value = System.getProperty("MP_TARGET")
                return values().find { it.name.equals(value, ignoreCase = true) } ?: ALL
            }
        }
    }

    В рамках нашего проекта я выделил четыре группы таргетов:


    1. ALL — подключаются и собираются все таргеты, используется для разработки и в качестве значения по умолчанию.
    2. COMMON — подключаются и собираются только Linux-совместимые таргеты. В нашем случае это JavaScript, JVM, Android JVM, Linux x64 и Linux ARM x32.
    3. IOS — подключаются и собираются только iOS-таргеты, используется для сборки на macOS.
    4. META — подключаются все таргеты, но собирается только модуль с метаинформацией для Gradle Metadata.

    При таком наборе групп таргетов мы можем распараллелить сборку проекта на три различные виртуальные машины (COMMON — Linux, IOS — macOS, META — Linux).


    В данный момент можно собирать всё на macOS, но у моего решения два преимущества. Во-первых, если мы решим реализовать поддержку Windows, нам нужно будет просто добавить новую группу таргетов и новую виртуальную машину на Windows для её сборки. Во-вторых, нет необходимости тратить ресурсы виртуальных машин на macOS на то, что можно собрать на Linux. CPU time на таких виртуальных машинах обычно в два раза дороже.


    Gradle Metadata


    Что же такое Gradle Metadata и для чего она нужна?


    В данный момент в Maven для разрешения зависимостей используется POM (Project Object Model).


    <?xml version="1.0" encoding="UTF-8"?>
    <project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
      <modelVersion>4.0.0</modelVersion>
      <groupId>com.jakewharton.rxbinding2</groupId>
      <artifactId>rxbinding-leanback-v17-kotlin</artifactId>
      <version>2.2.0</version>
      <packaging>aar</packaging>
      <name>RxBinding Kotlin (leanback-v17)</name>
      <description>RxJava binding APIs for Android's UI widgets.</description>
      <url>https://github.com/JakeWharton/RxBinding/</url>
      <licenses>
        <license>
          <name>The Apache Software License, Version 2.0</name>
          <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
          <distribution>repo</distribution>
        </license>
      </licenses>
      <developers>
        <developer>
          <id>jakewharton</id>
          <name>Jake Wharton</name>
        </developer>
      </developers>
      <scm>
        <connection>scm:git:git://github.com/JakeWharton/RxBinding.git</connection>
        <developerConnection>scm:git:ssh://git@github.com/JakeWharton/RxBinding.git</developerConnection>
        <url>https://github.com/JakeWharton/RxBinding/</url>
      </scm>
      <dependencies>
        <dependency>
          <groupId>com.android.support</groupId>
          <artifactId>support-annotations</artifactId>
          <version>28.0.0</version>
          <scope>compile</scope>
        </dependency>
      </dependencies>
    </project>
    

    В POM-файле указывается информация о версии библиотеки, её создателе и необходимых зависимостях.


    А что, если мы хотим иметь две версии библиотеки для разных JDK? Например, kotlin-stdlib имеет две версии: kotlin-stdlib-jdk8 и kotlin-stdlib-jdk7. Пользователям необходимо самим подключать нужную версию.


    При обновлении версии JDK легко забыть о внешних зависимостях. Именно для решения этой проблемы и была создана Gradle Metadata, которая позволяет добавлять дополнительные условия подключения той или иной библиотеки.


    Одним из поддерживаемых атрибутов Gradle Metadata является org.gradle.jvm.version, в котором указывается версия JDK. Поэтому для kotlin-stdlib файл метаданных в упрощённом виде мог бы выглядеть следующим образом:


    {
      "formatVersion": "1.0",
      "component": {
        "group": "org.jetbrains.kotlin",
        "module": "kotlin-stdlib",
        "version": "1.3.0"
      },
      "variants": [
        {
          "name": "apiElements",
          "attributes": {
            "org.gradle.jvm.version": 8
          },
          "available-at": {
            "url": "../../kotlin-stdlib-jdk8/1.3.0/kotlin-stdlib-jdk8.module",
            "group": "org.jetbrains.kotlin",
            "module": "kotlin-stdlib-jdk8",
            "version": "1.3.0"
          }
        },
        {
          "name": "apiElements",
          "attributes": {
            "org.gradle.jvm.version": 7
          },
          "available-at": {
            "url": "../../kotlin-stdlib-jdk7/1.3.0/kotlin-stdlib-jdk7.module",
            "group": "org.jetbrains.kotlin",
            "module": "kotlin-stdlib-jdk7",
            "version": "1.3.0"
          }
        }
      ]
    }

    Конкретно в нашем случае reaktive-1.0.0-rc1.module в упрощённом виде выглядит так:


    {
      "formatVersion": "1.0",
      "component": {
        "group": "com.badoo.reaktive",
        "module": "reaktive",
        "version": "1.0.0-rc1",
        "attributes": {
          "org.gradle.status": "release"
        }
      },
      "createdBy": {
        "gradle": {
          "version": "5.4.1",
          "buildId": "tv44qntk2zhitm23bbnqdngjam"
        }
      },
      "variants": [
        {
          "name": "android-releaseRuntimeElements",
          "attributes": {
            "com.android.build.api.attributes.BuildTypeAttr": "release",
            "com.android.build.api.attributes.VariantAttr": "release",
            "com.android.build.gradle.internal.dependency.AndroidTypeAttr": "Aar",
            "org.gradle.usage": "java-runtime",
            "org.jetbrains.kotlin.platform.type": "androidJvm"
          },
          "available-at": {
            "url": "../../reaktive-android/1.0.0-rc1/reaktive-android-1.0.0-rc1.module",
            "group": "com.badoo.reaktive",
            "module": "reaktive-android",
            "version": "1.0.0-rc1"
          }
        },
        {
          "name": "ios64-api",
          "attributes": {
            "org.gradle.usage": "kotlin-api",
            "org.jetbrains.kotlin.native.target": "ios_arm64",
            "org.jetbrains.kotlin.platform.type": "native"
          },
          "available-at": {
            "url": "../../reaktive-ios64/1.0.0-rc1/reaktive-ios64-1.0.0-rc1.module",
            "group": "com.badoo.reaktive",
            "module": "reaktive-ios64",
            "version": "1.0.0-rc1"
          }
        },
        {
          "name": "linuxX64-api",
          "attributes": {
            "org.gradle.usage": "kotlin-api",
            "org.jetbrains.kotlin.native.target": "linux_x64",
            "org.jetbrains.kotlin.platform.type": "native"
          },
          "available-at": {
            "url": "../../reaktive-linuxx64/1.0.0-rc1/reaktive-linuxx64-1.0.0-rc1.module",
            "group": "com.badoo.reaktive",
            "module": "reaktive-linuxx64",
            "version": "1.0.0-rc1"
          }
        },
      ]
    }

    Благодаря атрибутам org.jetbrains.kotlin Gradle понимает, в каком случае какую зависимость подтягивать в нужный source set.


    Включить метаданные можно с помощью:


    enableFeaturePreview("GRADLE_METADATA")

    Подробную информацию вы можете найти в документации.


    Настройка публикации


    После того как мы разобрались с таргетами и параллелизацией сборки, нужно настроить, что именно и как мы будем публиковать.


    Для публикации мы используем Gradle Bintray Plugin, поэтому первым делом обратимся к его README и настроим информацию о нашем репозитории и credentials для публикации.


    Всю конфигурацию будем производить в собственном плагине в папке buildSrc.
    Использование buildSrc даёт ряд преимуществ, среди которых всегда работающий автокомплит (в случае с Kotlin-скриптами он до сих пор не всегда работает и часто требует вызова apply dependencies), возможность переиспользования объявленных в нём классов и доступ к ним из Groovy и Kotlin-скриптов. Вы можете посмотреть пример использования buildSrc с последнего Google I/O (секция работы с Gradle).


    private fun setupBintrayPublishingInformation(target: Project) {
        // Подключим Bintray Plugin к проекту
        target.plugins.apply(BintrayPlugin::class)
        // И настроим его
        target.extensions.getByType(BintrayExtension::class).apply {
            user = target.findProperty("bintray_user")?.toString()
            key = target.findProperty("bintray_key")?.toString()
            pkg.apply {
                repo = "maven"
                name = "reaktive"
                userOrg = "badoo"
                vcsUrl = "https://github.com/badoo/Reaktive.git"
                setLicenses("Apache-2.0")
                version.name = target.property("reaktive_version")?.toString()
            }
        }
    }

    Я использую три динамических свойства проекта: bintray_user и bintray_key, которые можно получить из настроек личного профиля на Bintray, и reaktive_version, которое задаётся в корневом файле build.gradle.


    Для каждого таргета Kotlin Multiplatform Plugin создаёт MavenPublication, который доступен в PublishingExtension.


    Воспользовавшись примером кода из документации Kotlin, который я приводил выше, мы можем создать такую конфигурацию:


    private fun createConfigurationMap(): Map<String, Boolean> {
        val mppTarget = Target.currentTarget()
        return mapOf(
            "kotlinMultiplatform" to mppTarget.meta,
            KotlinMultiplatformPlugin.METADATA_TARGET_NAME to mppTarget.meta,
            "jvm" to mppTarget.common,
            "js" to mppTarget.common,
            "androidDebug" to mppTarget.common,
            "androidRelease" to mppTarget.common,
            "linuxX64" to mppTarget.common,
            "linuxArm32Hfp" to mppTarget.common,
            "iosArm32" to mppTarget.ios,
            "iosArm64" to mppTarget.ios,
            "iosX64" to mppTarget.ios
        )
    }

    В этой простой мапе мы описываем, какие публикации должны релизиться на конкретной виртуальной машине. Название публикации — это название таргета. Эта конфигурация полностью соответствует описанию групп таргетов, которое я привёл выше.


    Приступим к настройке публикации в Bintray. Bintray-плагин создаёт BintrayUploadTask, которую мы и будем настраивать под свои нужды.


    private fun setupBintrayPublishing(
        target: Project,
        taskConfigurationMap: Map<String, Boolean>
    ) {
        target.tasks.named(BintrayUploadTask.getTASK_NAME(), BintrayUploadTask::class) {
            doFirst {
                // Конфигурация здесь
            }
        }
    }

    Каждый, кто начинает работать с плагином Bintray, быстро обнаруживает, что его репозиторий давно покрылся мхом (последнее обновление было около полугода назад), и что все проблемы решаются всякими хаками и костылями во вкладке Issues. Поддержку такой новой технологии, как Gradle Metadata, не реализовали, но в соответствующем issue можно найти решение, которое мы и используем.


    val publishing = project.extensions.getByType(PublishingExtension::class)
    publishing.publications
        .filterIsInstance<MavenPublication>()
        .forEach { publication ->
            val moduleFile = project.buildDir.resolve("publications/${publication.name}/module.json")
            if (moduleFile.exists()) {
                publication.artifact(object : FileBasedMavenArtifact(moduleFile) {
                    override fun getDefaultExtension() = "module"
                })
            }
        }

    С помощью этого кода мы добавляем в список артефактов для публикации файл module.json, за счёт которого и работает Gradle Metadata.


    Но на этом наши проблемы не заканчиваются. При попытке запустить bintrayPublish ничего не произойдёт.


    В случае обычных Java- и Kotlin-библиотек Bintray автоматически подтягивает доступные публикации и публикует их. Однако в случае с Kotlin Multiplatform во время автоматического подтягивания публикаций плагин просто падает с ошибкой. И да, для этого тоже есть issue на GitHub. И мы снова воспользуемся решением оттуда, только отфильтровав нужные нам публикации.


    val publications = publishing.publications
        .filterIsInstance<MavenPublication>()
        .filter {
            val res = taskConfigurationMap[it.name] == true
            logger.warn("Artifact '${it.groupId}:${it.artifactId}:${it.version}' from publication '${it.name}' should be published: $res")
            res
        }
        .map {
            logger.warn("Uploading artifact '${it.groupId}:${it.artifactId}:${it.version}' from publication '${it.name}'")
            it.name
        }
        .toTypedArray()
    setPublications(*publications)

    Но этот код тоже не работает!


    Всё потому, что у bintrayUpload нет в зависимостях задачи, которая бы собрала проект и создала необходимые для публикации файлы. Самое очевидное решение — задать publishToMavenLocal как зависимость bintrayUpload, но не всё так просто.


    При сборке метаданных мы подключаем все таргеты к проекту, а значит, publishToMavenLocal вызовет компиляцию всех таргетов, так как в зависимостях этой задачи есть publishToMavenLocalAndroidDebug, publishToMavenLocalAndroiRelase, publishToMavenLocalJvm и т. д.


    Поэтому мы создадим отдельную прокси-задачу, в зависимости которой поставим только те publishToMavenLocalX, которые нам нужны, а саму эту задачу поставим в зависимости bintrayPublish.


    private fun setupLocalPublishing(
        target: Project,
        taskConfigurationMap: Map<String, Boolean>
    ) {
        target.project.tasks.withType(AbstractPublishToMaven::class).configureEach {
            val configuration = publication?.name ?: run {
                // Android-плагин не сразу задаёт публикацию у задачи PublishToMaven, поэтому мы находим её имя эвристическим методом
                val configuration = taskConfigurationMap.keys.find { name.contains(it, ignoreCase = true) }
                logger.warn("Found $configuration for $name")
                configuration
            }
            // Включим или отключим задачу в зависимости от текущей конфигурации
            enabled = taskConfigurationMap[configuration] == true
        }
    }
    
    private fun createFilteredPublishToMavenLocalTask(target: Project) {
        // Создадим прокси-задачу и поставим ей в зависимости только включенные задачи publishToMavenLocal
        target.tasks.register(TASK_FILTERED_PUBLISH_TO_MAVEN_LOCAL) {
            dependsOn(project.tasks.matching { it is AbstractPublishToMaven && it.enabled })
        }
    }

    Остаётся только собрать весь код воедино и применить получившийся плагин к проекту, в котором требуется публикация.


    abstract class PublishPlugin : Plugin<Project> {    
        override fun apply(target: Project) {
            val taskConfigurationMap = createConfigurationMap()
            createFilteredPublishToMavenLocalTask(target)
            setupLocalPublishing(target, taskConfigurationMap)
            setupBintrayPublishingInformation(target)
            setupBintrayPublishing(target, taskConfigurationMap)
        }

    apply plugin: PublishPlugin

    Полный код PublishPlugin вы можете найти в нашем репозитории здесь.


    Настройка Travis CI


    Самая сложная часть уже позади. Осталось настроить Travis CI так, чтобы он распараллелил сборку и публиковал артефакты в Bintray при выходе новой версии.


    Выход новой версии мы будем обозначать созданием тега на коммите.


    # Используем матричный билд (параллельное выполнение)
    matrix:
      include:
        # На Linux с Android и Chrome для сборки таргетов JS, JVM, Android JVM и Linux 
        - os: linux
          dist: trusty
          addons:
            chrome: stable
          language: android
          android:
            components:
              - build-tools-28.0.3
              - android-28
          # Используем MP_TARGET, чтобы задать необходимую группу таргетов для сборки
          env: MP_TARGET=COMMON
          # Мы можем пропустить шаг install — Gradle подтянет все зависимости сам
          install: true
          # При сборке под JVM мы также собираем библиотеку совместимости с RxJava2 
          script: ./gradlew reaktive:check reaktive-test:check rxjava2-interop:check -DMP_TARGET=$MP_TARGET
        # На macOS для сборки iOS-таргетов
        - os: osx
          osx_image: xcode10.2
          language: java
          env: MP_TARGET=IOS
          install: true
          script: ./gradlew reaktive:check reaktive-test:check -DMP_TARGET=$MP_TARGET
        # На Linux для сборки метаданных
        - os: linux
          language: android
          android:
            components:
              - build-tools-28.0.3
              - android-28
          env: MP_TARGET=META
          # Сборка метаданных не требует каких-либо проверок
          install: true
          script: true
    # Рекомендованные параметры кеширования Gradle (чтобы каждый раз не загружать все зависимости между билдами на одной ветке)
    before_cache:
      - rm -f  $HOME/.gradle/caches/modules-2/modules-2.lock
      - rm -fr $HOME/.gradle/caches/*/plugin-resolution/
    cache:
      directories:
        - $HOME/.gradle/caches/
        - $HOME/.gradle/wrapper/
        # Кешируем папку, которую использует Kotlin/Native для своих зависимостей
        - $HOME/.konan/
    # Запускаем публикацию артефактов в Bintray при создании нового тега, этот блок будет выполняться на каждой виртуальной машине из матрицы
    deploy:
      skip_cleanup: true
      provider: script
      script: ./gradlew bintrayUpload -DMP_TARGET=$MP_TARGET -Pbintray_user=$BINTRAY_USER -Pbintray_key=$BINTRAY_KEY
      on:
        tags: true

    Если по какой-то причине сборка на одной из виртуальных машин завершилась с ошибкой, то метаданные и остальные таргеты всё равно будут загружены на сервер Bintray. Именно поэтому мы не добавляем блок с автоматическим релизом библиотеки на Bintray через их API.


    При релизе версии нужно удостовериться, что всё в порядке, и просто нажать на кнопку публикации новой версии на сайте, поскольку все артефакты уже выгружены.


    Заключение


    Таким образом мы настроили continuous integration и continuous delivery в нашем проекте Kotlin Multiplatform.


    Распараллелив задачи сборки, запуска тестов и публикации артефактов, мы эффективно используем предоставленные нам на бесплатной основе ресурсы.


    И если вы используете в работе Linux (как Аркадий Иванов arkivanov, автор библиотеки Reaktive), то вам больше нет нужды просить человека, использующего macOS (меня), опубликовать библиотеку каждый раз при выходе новой версии.


    Надеюсь, после выхода этой статьи больше проектов начнут использовать этот подход для автоматизации рутинных действий.


    Спасибо за внимание!

    • +22
    • 2,1k
    • 2
    Badoo
    389,19
    Big Dating
    Поделиться публикацией

    Комментарии 2

      +3
      Всегда радует, когда badoo пишет про Kotlin Multiplatform. Сразу появляется надежда на MVICore Multiplatform. )
      А то пока приходится пользоваться своим кроссплатформенным велосипедом, который хоть и едет, но не такой красивый.
        +1

        Мы уже начали экспериментировать интеграцией MVICore с Reaktive, но публичных результатов в ближайшее время не будет. А пока в качестве теста мы для небольшого внутреннего продукта сделали сильно урезанную версию MVICore, в которой нет Postprocessor, News и некоторых других вещей.


        Так что советую пока пользоваться своим собственным велосипедом, ведь он уже ездит.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое