Знайомство з Kotlin/JS — поєднуємо солодке та гірке

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Ця стаття є частиною циклу «Монструозний Kotlin». Ось перша і друга частини. У цій статті ми трішки допишемо Cat Facts SDK, навчимося його тестувати і познайомимося з Kotlin/JS, а саме: напишемо браузерний застосунок з використанням React та нашого SDK (попереджаю — я не вчив React, увесь код, пов’язаний з React, показаний лише в цілях демонстрації та згенерований ChatGPT). Так, це мізер, але паралельно ви дізнаєтесь про всі необхідні нюанси: як налаштувати Karma та Mocha у Kotlin/JS та мультиплатформенних бібліотеках, як використовувати і мультиплатформенні, і npm-залежності через Gradle, а може, і використаєте отримані знання на практиці вже сьогодні. У попередній частині ми з вами перетворювали Cat Facts SDK у мультиплатформенну бібліотеку, мемно понаступали на усі граблі та навчились запускати його через Kotlin/Native на Linux (до речі, ви вже додали підтримку Windows та macOS? Якщо так і на вінді, то співчуваю, MinGW це справжня ср*ка-ґузиця. З нетерпінням чекаю посилання на ваші форки у коментарях, на найцікавіші дам фідбек), а ця стаття вже буде цікава фронтендерам.

Головне у використанні зброї — правильно прицілитись

І тому я швиденько накидав цікавий недо-DSL, котрий дозволяє конфігурувати base url та довжину фактів за замовчуванням. Він виглядає ось так:

val sdk = CatFacts {
    // отут-во можна конфігурувати
    baseUrl = "https://any.domain.that.implements.api.com"
    defaultFactLength = 50
}

Код дуже простий, щоб отримати щось подібне треба додати у конструктор extension-лямбду:

public class CatFacts(configLambda: CatFactsConfig.() -> Unit = {}) {
    ...
}

...та створити конфіг-холдер, клас, котрий буде тримати наші конфіги:

public class CatFactsConfig {  
    public var defaultFactLength: Int? = null  
    public var baseUrl: String = "https://catfact.ninja"  
}

Далі виставити дефолтні аргументи для властивостей всередині класу:

private var config = CatFactsConfig().apply(configLambda)  
private val client = getClient(Url(config.baseUrl))

// ...

public suspend fun getCatFact(maxLength: Int? = config.defaultFactLength) // ...

Автоматизуємо дегустацію

QA без хліба не залишаться, особливо ті, хто використовує Kotlin згідно з нещодавньою зарплатною аналітикою DOU — у них медіана $3,3K. Щоб додати можливість тестування треба, просто додати залежності у сорс-сет commonTest:

val commonTest by getting {
    dependencies {
        implementation(kotlin("test"))
        implementation(kotlin("test-annotations-common"))
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.2")
    }
}

...та додати корутини у commonMain (тестувати будемо за їх допомогою):

val commonMain by getting {
    dependencies {
        ...
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.2")
    }
}

Синкуємо та біжимо створювати папку commonTest/kotlin у src. Усередині створимо клас SDKTest. Але що ж ми будемо тестувати? Давайте для початку напишемо тест, що створить SDK та напише нам факт:

class SDKTest {
    @Test
    fun testCreateSDKWithDefaultConfig() = runTest {
        val sdk = CatFacts()
        
        sdk.getCatFact()
    }
}

У Gradle буде цікавий task з назвою allTests. Запустімо... Іііі... JVM-тести впадуть на шпагат під тиском часу:

After waiting for 10s, the test coroutine is not completing

Упс, я вперся у тайм-аут, таке буває, коли поганий інтернет. Давайте це виправимо...

@Test  
fun testCreateSDKWithDefaultConfig() = runTest(timeout = 30.seconds) { // додаємо параметр тут
    ...
}

Якщо у вас так само впали тести через тайм-аут, вони у вас все одно падатимуть, щоправда, тільки JS-тести. Щоб це виправити для браузерних тестів треба створити папку karma.config.d та залити туди js-файл, у якому отаке:

config.set({
    client: {
        mocha: {
            timeout: 30000
        }
    }
});

Як назвете файл — неважливо. Я от, наприклад, назву його putin-xuilo.js. Взагалі, всередині ви можете створити нескінченну кількість конфігів — усі вони будуть злиті в один файл компілятором Kotlin/JS. А щоб виправити це у Node-тестах треба піти до біса у build.gradle.kts (а різниця між бісом та Gradle?) і там, де ми декларуємо підтримку JS, додати таку лямбду:

js(IR) {
    browser()
    nodejs {
        testTask(Action {
            useMocha {
                timeout = "20s"
            }
        })
    }
}

Але... У мене Chrome у Flatpak — це контейнерна фігня та все таке, коротше, хром «у в’язниці» якомога далі від основної системи за злиття даних гуглу, а Karma із Flatpak-браузерами не сильно хоче працювати, тому браузерні тести в мене впадуть, а ось нода — ні. Щоб змусити тести працювати, мені треба встановити Firefox або будь-який інший браузер через пакетний менеджер (я на лінуксі, і у Windows все має працювати й так) та зареєструвати його у Gradle ось так:

js(IR) {
    browser {
        testTask(Action {
            useKarma {
                useFirefox() // отут-во
            }
        })
    }
    nodejs {
        ...
    }
}

Тепер нумо запускати тест — хай тортик продегустується:

./gradlew allTests

Тортик смачний, офіційно.

Уважні помітять ще один інгредієнт у тесті (тісті?) — createConfigurableSDK(). Цей тест я написав, доки сумував, бо очікувати на мобільний інтернет у селі — така собі справа. Навіть тимчасово таймаути у 5 хвилин усюди викрутив (у коді на GitHub так і залишились звичайні таймаути, що писались тут). Ось його код:

@Test
fun createConfigurableSDK() = runTest(timeout = 5.minutes) { // на гітхабі досі 30 сек
    val sdk = CatFacts {
        defaultFactLength = 50
    }

    repeat(5) {
        val fact = sdk.getCatFact()
        assertTrue(fact.fact.length <= 50)
    }
}

Кайф. Тепер торту можна обрати максимальну довжину та смак у вигляді base url :)

Експортуємо солодкий SDK у гіркий JS

Настав час скористатись анотацією @JsExport — з її допомогою ми поставимо мітку на тому, що ми хочемо, щоб це було видимим у JS. Але просто взяти і помітити нею увесь код не вийде, бо на цей час вона в альфі, і сильно обмежена (навіть шоколадну посипку не всюди можеш посипати!). Можна експортувати лише те, що повертає/ приймає прості типи, або класи, що також помічені як експортні, та не має у собі suspend. Так, у серйозний форум щойно була принесена альфа-технологія, бійтеся солодко-радіоактивного зараження! І давайте так само серйозно покумекаємо, що ж нам із цим робити. Можна написати platform-friendly обгортку над SDK у jsMain, а можна зробити окремий адаптер (корисно для більших проєктів). Я піду першим шляхом. Класи Fact та CatFactsConfig можна просто анотувати, бо там нічого окрім стрингу та інту і нема:

@OptIn(ExperimentalJsExport::class)  
@JsExport  
@Serializable
public data class Fact(val fact: String)
@OptIn(ExperimentalJsExport::class)  
@JsExport  
public class CatFactsConfig {  
    public var defaultFactLength: Int? = null  
    public var baseUrl: String = "https://catfact.ninja"  
}

Тепер можна писати і обгортку над CatFacts, для цього задля зручності додамо вторинний конструктор у SDK:

public class CatFacts(config: CatFactsConfig.() -> Unit = {}) {
    private val client = getClient()
    private var config = CatFactsConfig().apply(config)

    public constructor(config: CatFactsConfig) : this() {
        this.config = config
    }
    ...
}

Тепер використаємо новий конструктор у класі-обгортці:

@OptIn(ExperimentalJsExport::class)  
@JsExport  
public class CatFactsJs(config: CatFactsJsConfig.() -> Unit = {})  {  
    private val config = CatFactsJsConfig().apply(config).toCommonConfig()  
    private val sdk = CatFacts(this.config)  
  
    @OptIn(DelicateCoroutinesApi::class)  
    public fun getCatFact(maxLength: Int? = config.defaultFactLength): Promise = GlobalScope.promise {  
        sdk.getCatFact(maxLength)  
    }  
}

Зараз знавці корутин мене закидають помідорами або обіллють липкою шипучкою, бо я використав GlobalScope, від використання якого застерігає документація та хмара опт-інів, але у конкретно даному випадку ми просто «промапили» цю функцію з Coroutine-середовища у JS, бо тепер контроль над потоком, де ця функція запускається, вже у JS на руках, ми її трансформували у так званий Promise. Це дозволить нам запускати її як звичайну async-функцію, і вести вона себе буде поза структурованими корутинами, але за правилами JS.

Пишемо сам вебзастосунок чи ні?

Окей, SDK солодкий, конфігурабельний, гнучкий, митницю пройде, бо експортабельний. Тепер можна і у JS його засунути. Кому цікаво — ось плагін для публікації. Kotlin/JS-бібліотек у npm — для тих, хто хоче написати на Kotlin/JS щось самотужки. Також пакет з SDK лежить у npm, я за вас усе опублікував, і інструкції як вам зробити так само не буде... Жартую, звісно що буде! У build.gradle.kts додаємо цей плагін:

plugins {  
    ...
    id("dev.petuska.npm.publish") version "3.4.1"  
}

...міняємо конфігурацію JS-компілятора, щоб він видавав бібліотеки:

js(IR) {  
    browser {  
        ...
    }  
    nodejs {  
        ...
    }  
    useCommonJs()  // для сумісності з нодою
    binaries.library()  // тепер він видаватиме нам бібліотеку
}

...та конфігуруємо саму публікацію:

npmPublish {
    registries {
        register("npmjs") {
            uri.set("https://registry.npmjs.org")
            authToken.set("npm_ваш_токен_тут")
        }
    }
}

Щоб опублікувати щось у npm, треба отримати токен, за мене багато разів розповідали, як це зробити, але мені допомогла документація. Після збиття gradle sync-блендером у нас з’явиться таска publishJsPackageToNpmjsRegistry. НЕ запускаємо її, бо вона все одно впаде, бо такий самий пакет вже є.

Тепер точно пишемо

Окей, у npm наш SDK вже є, тепер прийшов час для чистого та гіркого JS, у якому і я не сильно розбираюсь. Створюємо новий React-застосунок, додаємо у package.json залежність на SDK:

{
  ...
  "dependencies": {
    ...
    "catfacts-sdk": "^1.5.2"
  },
  ...
}

Тепер заходимо у App.js, імпортуємо наш SDK:

import module from "catfacts-sdk"

По дефолту усі бібліотеки пакуються вебпаком у модуль, названий module. Змінити це ім’я можна у build.gradle.kts, ось так:

kotlin {
    js(IR) {
        moduleName = "catfactsSdk"
        // ...
    }
}

Тепер можна імпортувати не module, а catfactsSdk:

import catfactsSdk from "catfacts-sdk"

Тепер, знаючи ці нюанси, ми можемо нарешті написати щось, що буде нарешті виводити їх на екран поза консоллю. Відредагуємо function App() таким чином:

const sdk = new catfactsSdk.com.bpavuk.catfacts.CatFactsJs()



function App() {
  const [fact, setFact] = useState(null);



  useEffect(() => { // щось на кшталт LaunchEffect із Compose
    sdk.getCatFact()
      .then((res) => { // оброблюємо проміс та отримуємо клас Fact
        setFact(res.fact); // дістаємо факт (пам'ятаєте те поле датакласу Fact?)
      })
      .catch((error) => {
        console.error('Error fetching cat fact:', error); // про всяк випадок
      });
  }, []); // Run this effect once on component mount



  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
        {fact ? <p>Your random cat fact is: {fact}</p> : <p>Loading cat fact...</p>}
      </header>
    </div>
  );
}



export default App;

Ну що ж, ви пригадуйте «Отче Наш» (а мені не треба, я атеїст), і запускаємо:

І знову сумний факт попався, хоч і корисний! Скільки можна :)) Офтоп: у Gnome, напевне, скоро з’явиться новий індикатор робочих просторів. Я вже встановив концепт-плагін, він заміняє напис Activities на такий приємний індикатор угорі. Ще й з анімаціями! Тепер пояснюю, що тут за чортівня:

  1. Ми створюємо SDK, але через гру джаваскрипту у модулі треба використовувати fully-qualified names, як тут: catfactsSdk.com.bpavuk.catfacts.CatFactsJs(). Цю проблему можна вирішити, порушивши структуру самої бібліотеки — перемістити обгортку CatFactsJs() у корінь сорс-сету JsMain. Тоді вийде просто catfactsSdk.CatFactsJs().
  2. Далі створюємо стейт та сетер для нього: const [fact, setFact] = useState(null);, ініціюємо нулем.
  3. Потім, у useEffect ми передаємо лямбду, де виконується єдина функція нашого SDK та є базовий хендлінг помилок.
  4. Далі ми повертаємо JSX HTML (сам не зрозумів, що це, навіть ChatGPT не зміг пояснити), у котрому прописаний контент сторінки — символіка нацистів React та наш невеличкий факт.

І знову повторюсь для тих, у кого шок, — ми написали логіку на Kotlin та використали її у JavaScript. А тепер ще більший шок — Compose є і для вебу, але це вже тема для четвертої частини статті.

І висновки

Ми зачепили декілька цікавих патернів і тем, навчились користуватись trailing lambda syntax, експортувати самописні бібліотеки у JS, «смикати» їх звідси та конфігурувати платформи для тестування під JS. У наступній, завершальній, частині ми вже будемо користуватись Compose for Web та Tailwind CSS, там ми зачепимо використання npm та бібліотек звідси у Gradle та Kotlin/JS (жесть, я стільки разів сказав «JS», хоч би джаваскриптизером не стати). Ця частина вже буде найцікавішою та найменш проблемною, андроїдщикам буде дуже цікаво, бо всі ми любимо Compose. До речі, мені 14, а JavaScript став моєю першою мовою програмування у 8 років :)

Для тих, кому мало солодкого

Тут я наведу посилання на усе, що вам може знадобитись у Kotlin/JS:

  1. Compose Multiplatform — якби ви були андроїдманом, ви б вже знепритомніли від щастя та передозу синтаксичного цукру.
  2. Kotlin Wasm — потенційно менш проблемна (але експериментальна) заміна Kotlin/JS.
  3. Увесь код, що ми писали — SDK, React-застосунок.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Так, але доки дуже обмежений. А завдяки Wasm можна буде писати нормально, а не з обмеженнями на кшталт «ніяких саспендів не експортувати»

Підписатись на коментарі