Знайомство з Kotlin/JS — поєднуємо солодке та гірке
Ця стаття є частиною циклу «Монструозний 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 на такий приємний індикатор угорі. Ще й з анімаціями! Тепер пояснюю, що тут за чортівня:
- Ми створюємо SDK, але через гру джаваскрипту у модулі треба використовувати fully-qualified names, як тут:
catfactsSdk.com.bpavuk.catfacts.CatFactsJs()
. Цю проблему можна вирішити, порушивши структуру самої бібліотеки — перемістити обгорткуCatFactsJs()
у корінь сорс-сетуJsMain
. Тоді вийде простоcatfactsSdk.CatFactsJs()
. - Далі створюємо стейт та сетер для нього:
const [fact, setFact] = useState(null);
, ініціюємо нулем. - Потім, у
useEffect
ми передаємо лямбду, де виконується єдина функція нашого SDK та є базовий хендлінг помилок. - Далі ми повертаємо 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:
- Compose Multiplatform — якби ви були андроїдманом, ви б вже знепритомніли від щастя та передозу синтаксичного цукру.
- Kotlin Wasm — потенційно менш проблемна (але експериментальна) заміна Kotlin/JS.
- Увесь код, що ми писали — SDK, React-застосунок.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів