Pull to refresh

Kotlin Native: следите за файлами

Reading time5 min
Views21K
Когда вы пишите command line утилиту, последнее, на что вам хочется полагаться, так это на то, что на компьютере где она будет запущена установлен JVM, Ruby или Python. Так же хотелось бы на выходе иметь один бинарный файл, который будет легко запустить. И не возиться слишком много с memory management'ом.

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

У Go относительно простой синтаксис, неплохая стандартная библиотека, есть garbage collection, и на выходе мы получаем один бинарник. Казалось бы, что еще нужно?

Не так давно Kotlin так же стал пробовать себя на схожем поприще в форме Kotlin Native. Предложение звучало многообещающе — GC, единый бинарник, знакомый и удобный синтаксис. Но все ли так хорошо, как хотелось бы?

Задача, которую нам предстоит решить: написать на Kotlin Native простой file watcher. Как аргументы утилита должна получать путь к файлу и частоту проверки. Если файл изменился, утилита дожна создать его копию в той же папке с новым именем.

Иначе говоря, алгоритм должен выглядеть следующим образом:

fileToWatch = getFileToWatch()
howOftenToCheck = getHowOftenToCheck()
while (!stopped) {
   if (hasChanged(fileToWatch)) {
      copyAside(fileToWatch)
   }   
   sleep(howOftenToCheck)
}

Ладно, с тем чего хотим добиться вроде бы разобрались. Время писать код.

Среда


Первое, что нам потребуется — это IDE. Любителей vim попрошу не беспокоиться.

Запускаем привычный IntelliJ IDEA и обнаруживаем, что в Kotlin Native он не может от слова совсем. Нужно использовать CLion.

На этом злоключения человека, который в последний раз сталкивался с C в 2004 еще не окончены. Нужен toolchain. Если вы используете OSX, скорее всего CLion обнаружит подходящий toolchain сам. Но если вы решили использовать Windows и на C не программируете, придется повозиться с tutorial'ом по установке какого-нибудь Cygwin.

IDE установили, с toolchain'ом разобрались. Можно уже начать код писать? Почти.
Поскольку Kotlin Native еще несколько экспериментален, плагин для него в CLion не установлен по умолчанию. Так что прежде, чем мы увидим заветную надпись «New Kotlin/Native Application» придется его установить вручную.

Немного настроек


И так, наконец-то у нас есть пустой Kotlin Native проект. Что интересно, он основан на Gradle (а не на Makefile'ах), да еще на Kotlin Script версии.

Заглянем в build.gradle.kts:

plugins {
    id("org.jetbrains.kotlin.konan") version("0.8.2")
}

konanArtifacts {
    program("file_watcher")
}

Единственный плагин, который мы будем использовать называется Konan. Он то и будет производить наш бинарный файл.

В konanArtifacts мы указываем имя исполняемого файла. В данном примере получится file_watcher.kexe

Код


Пора бы уже и код показать. Вот он, кстати:

fun main(args: Array<String>) {
    if (args.size != 2) {
        return println("Usage: file_watcher.kexe <path> <interval>")
    }

    val file = File(args[0])
    val interval = args[1].toIntOrNull() ?: 0

    require(file.exists()) {
        "No such file: $file"
    }

    require(interval > 0) {
        "Interval must be positive"
    }

    while (true) {
        // We should do something here
    }
}

Обычно у command line утилиты бывают так же опциональные аргументы и их значения по умолчанию. Но для примера будем предполагать, что аргумента всегда два: path и interval

Для тех, кто с Kotlin уже работал можем показаться странным, что path оборачивается в свой собственный класс File, без использования java.io.File. Объяснение этому — черезе минуту-другую.

Если вы вдруг не знакомы с функцией require() в Kotlin — это просто более удобный способ для валидации аргументов. Kotlin — он вообще про удобство. Можно было бы написать и так:

if (interval <= 0) {
   println("Interval must be positive")
   return
}

В целом, тут пока обычный Kotlin код, ничего интересного. А вот с этого момента станет повеселей.

Давайте будем пытаться писать обычный Kotlin-код, но каждый раз, когда нам нужно использовать что-нибудь из Java, мы говорим «упс!». Готовы?

Вернемся к нашему while, и пусть он отпечатывает каждый interval какой-нибудь символ, к примеру точку.


var modified = file.modified()
while (true) {
    if (file.modified() > modified) {
        println("\nFile copied: ${file.copyAside()}")
        modified = file.modified()
    }
    print(".")
    // Упс...
    Thread.sleep(interval * 1000)
}

Thread — это класс из Java. Мы не можем использовать Java классы в Kotlin Native. Только Kotlin'овские классы. Никакой Java.

Кстати, потому в main мы и не использовали java.io.File

Хорошо, а что тогда можно использовать? Функции из C!

var modified = file.modified()
while (true) {
    if (file.modified() > modified) {
        println("\nFile copied: ${file.copyAside()}")
        modified = file.modified()
    }
    print(".")
    sleep(interval)
}

Добро пожаловать в мир C


Теперь, когда мы знаем что нас ждет, давайте посмотрим как выглядит функция exists() из нашего File:

data class File(private val filename: String) {
    fun exists(): Boolean {
        return access(filename, F_OK) != -1
    }
    // More functions...
}

File это простой data class, что дает нам имплементацию toString() из коробки, которой мы потом воспользуемся.

«Под капотом» мы вызываем C функцию access(), которая возвращает -1, если такого файла не существует.

Дальше по списку у нас функция modified():

fun modified(): Long = memScoped {
    val result = alloc<stat>()
    stat(filename, result.ptr)

    result.st_mtimespec.tv_sec
}

Функцию можно было бы немного упростить используя type inference, но тут я решил этого не делать, чтобы было понятно, что функция не возвращает, к примеру, Boolean.

В этой фукнции есть две интересные детали. Во-первых, мы используем alloc(). Поскольку мы используем C, иногда нужно выделять структуры, а делается это в C вручную, при помощи malloc().

Высвобождать эти структуры тоже нужно вручную. Тут на помощь приходит функция memScoped() из Kotlin Native, которая это сделает за нас.

Нам осталось рассмотреть наиболее увесистую функцию: сopyAside()

fun copyAside(): String {
        val state = copyfile_state_alloc()
        val copied = generateFilename()

        if (copyfile(filename, copied, state, COPYFILE_DATA) < 0) {
            println("Unable to copy file $filename -> $copied")
        }
        copyfile_state_free(state)
        return copied
}

Тут мы используем С функцию copyfile_state_alloc(), которая выделяет нужную для copyfile() структуру.

Но и высвобождать нам эту структуру приходится самим — используя
copyfile_state_free(state)

Последнее, что осталось показать — это генерация имен. Тут просто немного Kotlin:

private var count = 0
private val extension = filename.substringAfterLast(".")

private fun generateFilename() = filename.replace(extension, "${++count}.$extension")

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

Пуск


Теперь как все это запускать?

Один вариант — это конечно использовать CLion. Он все сделает за нас.

Но давайте вместо этого используем command line, чтобы лучше понять процесс. Да и какой-нибудь CI не будет запускать наш код из CLion.

./gradlew build && ./build/konan/bin/macos_x64/file_watcher.kexe ./README.md 1

Первым делом мы компилируем наш проект используя Gradle. Если все прошло успешно, появится следующее сообщение:

BUILD SUCCESSFUL in 16s

Шестнадцать секунд?! Да, в сравнение с каким-нибудь Go или даже Kotlin для JVM, результат неутешителен. И это еще крошечный проект.

Теперь вы должны увидеть бегущие по экрану точки. И если вы измените содержимое файла, об этом появится сообщение. Что-то вроде такой картины:

................................
File copied: ./README.1.md
...................
File copied: ./README.2.md

Время запуска замерить сложно. Зато мы можем проверить, сколько памяти занимает наш процесс, используя к примеру Activity Monitor: 852KB. Неплохо!

Немного выводов


И так, мы выяснили что при помощи Kotlin Native мы можем получить единый исполняемый файл с memory footprint'ом меньше, чем у того же Go. Победа? Не совсем.

Как это все тестировать? Те кто работал с Go или Kotlin'ом знаю, что в обоих языках есть хорошие решения для этой важной задачи. У Kotlin Native с этим пока что все плохо.

Вроде бы в 2017ом JetBrains пытались это решить. Но учитывая, что даже у официальных примеров Kotlin Native нет тестов, видимо пока не слишком успешно.

Другая проблема — crossplatform разработка. Те, кто работали с C побольше моего уже наверняка заметили, что мой пример будет работать на OSX, но не на Windows, поскольку я полагаюсь на несколько функций доступных только с platform.darwin. Надеюсь что в будущем у Kotlin Native появится больше оберток, которые позволят абстрагироваться от платформы, к примеру при работе с файловой системой.

Все примеры кода вы можете найти тут

И ссылка на мою оригинальную статью, если вы предпочитаете читать на английском
Tags:
Hubs:
+6
Comments42

Articles

Change theme settings