Привет, Хабр. Я уже не тот беззаботный парнишка, что собирал 486-й компьютер. Время бежит, я скуф успел поработать с C++, C#, Java и Kotlin, последние 10 лет занимаюсь мобильной разработкой. Программирование я до сих пор люблю потому что больше ничего не умею. Но чем дольше работаешь, тем чаще ловишь себя на мысли, что от некоторых вещей начинает подгорать. Сегодня я расскажу, как я попытался избавиться хотя бы от части таких вещей.

Немного о себе. Я начал изучать программирование в 2005 году, когда пошел в восьмой класс. Один друг поделился со мной диском, на котором были записаны Flash-игры и эмуляторы, и я подумал, что раз кто-то в принципе готов играть на компьютере не только в Far Cry, Doom 3, NFS Most Wanted, а ещё и в это, у меня тоже есть шанс запилить игрушку и воплотить в жизнь какие-то свои идеи. Спойлер: не запилил. Для старта я выбрал Delphi. Во-первых, язык был достаточно понятным для новичка, а во-вторых, для него был DelphiX — обёртка над DirectX, которая позволяла относительно просто делать игры. На тот момент мы даже ещё не проходили тригонометрию в школе, поэтому я тратил часы, меняя цифры в непонятных скопипащенных формулах, пытаясь разобраться, почему персонаж когда-то прыгает нормально, а иногда вылетает за экран. Так почему же тогда я проводил за монитором дни напролёт и получал от этого кайф, а сейчас, когда я только начинаю проект, всё идёт хорошо, но постепенно добавление каждой новой фичи, каждого нового экрана начинает вызывать почти физическую боль?

Со временем я понял, в чем дело. Когда трава была зеленее и я пилил свой клон Марио на DelphiX, мне было достаточно одной функции onTimerTick() на всё. Весь код представлял из себя огромный файл и я не видел в этом проблемы. Теперь у нас есть промышленные стандарты, Архитектура. Теперь у нас не программы, а приложения, и в приложение нельзя просто добавить строчку без того, чтобы объявить юзкейс, интерфейс юзкейса, интерфейс репозитория, сам репозиторий, domain-модель, модель данных, конвертер из модели данных в domain-модель, интерфейс конвертера из модели данных в domain-модель, дата-соурс, провайдер для дата-сорса, di модуль, ну вы поняли… На одном проекте я насчитал 20+ обязательных сущностей для каждого экрана. И нет, я не против архитектуры. Архитектура решает реальные проблемы. Но вместе с этим она приносит огромное количество повторяющегося кода. А вот каждый раз писать руками тонны бойлерплейта уже утомительно. Да, на крупных проектах иногда есть какие-то тулзы для разворачивания новых фич, но, во-первых, лично вам о них могут просто забыть сказать, во-вторых, ежели это сделано в виде плагина к нашей любимой Android Studio, нередка ситуация, когда AS обновилась, а плагин еще нет, и вот и усё, карачун тебе, Церетели. Да и для моего Pet-проекта никто кроме меня самого такое решение не напишет.

Когда появились нейросети, я попробовал генерировать с их помощью параметризованные шелл-скрипты для генерации заготовок кода, чтобы облегчить жизнь. И в принципе, это даже работает. Проблема только в том, что если делать это под каждый проект, каждый раз наступаешь на одни и те же грабли, да и моя цель была всё-таки максимально уменьшить рутину. И в один момент я подумал о том, что хорошо было бы один раз сделать утилиту для генерации кода, обложить ее тестами, а меняться от проекта к проекту будут только шаблоны. И ещё было бы здорово вообще не писать шаблоны руками. Закинул в бота пример своего кода и получаешь шаблон. Так и родилась идея Featuregen.

Featuregen - это генератор файлов на основе шаблонов, написанный на POSIX Shell. По сути это небольшой DSL, который может быть использован для генерации исходников, конфигураций и других файлов. Вы можете задать вполне валидный вопрос почему было не взять что-нибудь из уже готовых генераторов, например cookiecutter или нечто подобное. Ну, главная причина в том что мне хотелось попробовать сделать всё самому. Когда я думал о том, как мог бы выглядеть идеальный результат этой работы, я представлял себе кусок кода, который можно скинуть в личку знакомому или коллеге, и он, просто скопипастив одну команду в терминал, получал бы полностью готовое окружение, без необходимости заниматься установкой ноды или пайтона. А это как раз может обеспечить шелл. Но проблема в том, что я не какой-то суперзнаток шелла. Да, я знал как запустить докер в CI/CD пайплайне, но давайте начистоту - шелл обладает устаревшим синтаксисом, к которому в наше время не то чтобы приятно прикасаться. Как я выкрутился? Решил, что буду работать по TDD, как пишут в умных книжках. Набросал простенький движок для тестов на основе JUnit. Движок берет шаблон, передает необходимые аргументы, прогоняет скрипт, сравнивает структуру и содержимое файлов на выходе. AI сгенерировал каркас скрипта, по мере необходимости я добавлял новые тесты и спустя какое-то время уже мог сам что-то править в коде скрипта. Я смирился с тем, что вряд ли у меня хватит терпения полностью разобраться в каждом символе матчинга строк на шелл, но если я в принципе хотя бы на верхнем уровне могу сказать про каждую строчку кода для чего она нужна, и все тесты проходят, и инструмент делает своё дело, меня это устраивает.

Вот пример простого шаблона Featuregen:

@expect name

@file path=Hello~<<name>>.txt
@@fstart
Hello, ~<<name>>!
@@fend

Здесь:

  • @expect объявляет обязательный параметр, который должен быть передан при запуске генератора

  • ~<<name>> вставляет значение переменной в текст

  • @file задаёт путь к создаваемому файлу

  • Всё между @@fstart и @@fend становится содержимым этого файла

Теперь, если запустить генератор с параметром name=World, результатом будет файл HelloWorld.txt с содержимым:

Hello, World!

Для реальных задач одной подстановки переменных мало, поэтому в Featuregen есть несколько встроенных функций преобразования строк:

camel(value)

Конвертирует текст в camelCase

snake(value)

Конвертирует текст в snake_case

screamingSnake(value)

Конвертирует текст в SCREAMING_SNAKE_CASE

pathFromPackage(value)

Конвертирует com.example.app вcom/example/app

extractIgnoringCase(value, remove)

Удаляет подстроку без учёта регистра

Пример шаблона для Android ViewModel
@expect package
@expect fullName

@let shortName = extractIgnoringCase(fullName, Feature)
@let snakeCaseName = snake(fullName)
@let packagePath = pathFromPackage(package)

@file path=~<<packagePath>>/~<<snakeCaseName>>/presentation/~<<shortName>>ViewModel.kt
@@fstart
package ~<<package>>.~<<snakeCaseName>>.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

internal class ~<<shortName>>ViewModel(
    screenMapper: ~<<shortName>>ScreenMapper,
) : ViewModel() {

    private val internalState = MutableStateFlow(~<<shortName>>ScreenInternalState.empty)

    private val _sideEffects = Channel<~<<shortName>>ScreenSideEffect>()
    val sideEffects = _sideEffects.receiveAsFlow()

    val uiState: StateFlow<~<<shortName>>ScreenViewState> =
        internalState
            .map(screenMapper::toViewState)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = screenMapper.toViewState(internalState.value),
            )
}
@@fend

Вызываем генератор так:

./featuregen template.featuregen \
    package=foo.bar \
    fullName=FeatureTest

На выходе получим:

package foo.bar.feature_test.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

internal class TestViewModel(
    screenMapper: TestScreenMapper,
) : ViewModel() {

    private val internalState = MutableStateFlow(TestScreenInternalState.empty)

    private val _sideEffects = Channel<TestScreenSideEffect>()
    val sideEffects = _sideEffects.receiveAsFlow()

    val uiState: StateFlow<TestScreenViewState> =
        internalState
            .map(screenMapper::toViewState)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = screenMapper.toViewState(internalState.value),
            )
}

Конечно, создавать шаблоны вручную было бы немного иронично: мы пытаемся избавиться от рутины, а в итоге создаём новую рутину. Поэтому я сделал Telegram-бота для генерации шаблона по образцу кода или описанию.

Как мобильный разработчик погружался в тему бэкенда, деплоя и вот этого всего — это отдельная история, которую, возможно, я расскажу в другой статье, если будет интерес.

А пока вы тоже можете попробовать сгенерировать шаблон через бота: @featuregen_bot.

У бота есть два режима работы:

  • template — бот генерирует только текст шаблона Featuregen. Подходит, если вы хотите посмотреть или доработать шаблон перед использованием.

  • bootstrap — бот генерирует готовую команду для терминала, которая скачивает Featuregen и создаёт файл шаблона. Этот вариант удобен, когда хочется получить рабочую заготовку максимально быстро.

Также можно написать гадость оставить фидбэк.

Получился ли идеальный инструмент? Пока нет. Сейчас Featuregen умеет только базовые вещи, и ему ещё есть куда расти. Но главное — он уже избавляет меня от части рутины и сохраняет некоторое количество нервных клеток. Поэтому проект не заброшен: я буду продолжать его развивать.