Spense.app "под капотом". Код.

Пока Spense v0.2 в разработке, хочу рассказать о внутренней организации приложения с технической точки зрения. Статья эта в основном для (веб-)разработчиков, и язык у нее соответствующий, поэтому если вы читаете и ничего не понимаете, все в порядке, можно просто не читать.

В двух словах

Бэкенд на Django (Python), фронтенд на Django-шаблонах и Bootstrap, с щепоткой JavaScript и местами htmx (уже нет).

Почему так скучно?

Звучит совсем не хайпово, да. Но стоит помнить, что Spense в текущем его состоянии - это не полноценный продукт. Это скорее прототип, в котором мне часто нужно что-то менять и проверять идеи. Если идеи сработают, я выкину этот код и напишу другой, а если не сработают, просто оставлю на память.

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

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

  • history - для моделей БД и кусочка логики про кошельки и транзакции;
  • tags - для моделей БД и кусочка логики вокруг тегов;
  • app - основные вьюхи с кучей логики внутри, HTML-шаблоны и статика.

Поначалу для меня это разделение имело смысл, но сейчас я скорее от этого страдаю, потому что до сих пор не могу запомнить, где какие модели лежат. Перемещать модели между Django-аппами - это отдельный геморрой, поэтому продолжаю страдать.

С фронтом все еще скучнее. Последний раз я писал более-менее сложный интерфейс на React в 2017 году, и после этого крайне редко касался современных JS фреймворков. Простого HTML мне хватало в большинстве случаев, и jQuery, как верный старый пёс, всегда приходил на помощь. Здесь я, правда, решил jQuery пока не вкатывать, а использовать вместо него "vanilla JS", но на практике выглядит всё похожим образом.

Например, вот кусочек про подгрузку "smart tags" - категорий расходов, которые Spense пытается угадать по введенной сумме:

{% block script %}
    <script>
        const $amount = document.getElementById('amount');
        const $wallet = document.getElementById('wallet');
        const $add = document.getElementById('add');
        const $listSmartTags = document.getElementsByClassName('smart-tags-list')[0];
        /* ... */

        $amount.addEventListener('keyup', function (e) {
            if (e.target.value) {
                $add.removeAttribute("disabled")
                updateSmartTags()
            } else {
                $add.setAttribute("disabled", "")
                $listSmartTags.innerHTML = '';
            }
        })

        /* ... */

        function updateSmartTags() {
            const walletId = $wallet.value;
            const amount = $amount.value;

            fetch(`{% url 'smart_tags' %}?wallet_id=${walletId}&amount=${amount}`)
                .then(res => res.json())
                .then(/* ... */)
            /* ... */
        }
    </script>
{% endblock %}
Тег {% url %} внутри JS-строки вперемешку с вклеенными JS-переменными - 🤌

Как видите, все довольно прямолинейно: скрипты я оставляю прямо в теге <script> на странице, и внутри там почти все как в jQuery, даже префикс $ имеется, чтобы отличать DOM-элементы от других идентификаторов.

Ну а по части Bootstrap - пока это для меня просто выбор по умолчанию. Там вроде Tailwind набирает обороты, но я пока не согласен к нему привыкать. Хоть в моем HTML тоже проскакивает class="row border-top border-black border-opacity-10 pt-1" или просто инлайновые стили в атрибуте style, я стараюсь выносить их в отдельный CSS-класс, если их становится много. Tailwind для таких случаев официально рекомендует multi-cursor editing, и я даже не знаю, как это комментировать.

Работа с кодом

На базовом уровне все тривиально: один git-репозиторий, которых хостится на GitHub внутри моей "организации".

В репозитории настроено несколько pre-commit-хуков, включая ruff --fix и ruff format (я почти везде переехал с flake8+black на ruff из-за того, что это очень шустрый швейцарский нож).

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

Но в прошлом году я понял, что это порождает некоторые трудности, и нужно хотя бы иметь базовую проверку кода и чистить неиспользуемые импорты. Ну а заодно и "причесывать" сам код, потому что против Black code style я вообще ничего не имею, и он мне даже нравится.

Так что в какой-то момент я всё отформатировал, починил, добавил хуки и воспользовался .git-blame-ignore-revs, чтобы git blame эти коммиты игнорировал.

Тесты

Для тестов я использую pytest + pytest-django, но тестов там сейчас очень мало. В основном получается так, что все фичи так или иначе связаны с интерфейсом, и я их просто руками протыкиваю по время разработки. А так как я еще и активный пользователь, то я автоматически становлюсь и ручным тестировщиком большинства фич. Конечно, код часто покрывает только happy path, с минимальной валидацией входных данных, но большего мне пока и не надо.

Зависимости

За Python-зависимости сейчас отвечает poetry. Сначала было просто pip install -r requirements.txt, пока зависимостей было штук 5, которые я два года не обновлял. Но довольно скоро стало понятно, что poetry+pyproject.toml гораздо приятнее и удобнее.

Для поддержания актуальности зависимостей я настроил бот Renovate, который находит новые версии вообще для всего, что у меня есть в репозитории. У Renovate есть как минимум два значимых отличия от dependabot:

  • он умеет собирать минорные апдейты в один pull request,
  • и он умеет сам его мёрджить (но не сразу, а когда у него есть настроение).

То есть в большинстве случаев от меня никаких действий вообще не требуется, разве что если CI на этом pull request ломается.

CHANGELOG.md

Я уже писал, что веду проект в GitHub Projects, но вдобавок я решил еще и записывать значимые изменения в CHANGELOG.md. Например, для версии 0.1 список изменений выглядит так:

Кстати, существует множество разных инструментов для ведения чейнджлогов. Я попробовал scriv, и процесс генерации чейнджлога мне сначала показался приятным.

Допустим, для следующей версии проекта запланирован десяток фич, которые разрабатываются в разных ветках, вливаются в master/main, потом в какой-то момент проект тегается новой версией, и создается релиз. Scriv позволяет создавать в процессе разработки отдельные файлы в каталоге changelod.d, по кусочку чейнджлога на каждую фичу. При этом каждый кусочек внутри себя может содержать и "Added", и "Removed", и "Fixed" секции. А когда нам нужно оформить релиз, мы запускаем scriv collect, который красиво сливает эти кусочки в один, берет номер новой версии из pyproject.toml и добавляет всё вместе к CHANGELOG.md. Так был сгенерирован список к Spense 0.1 на скриншоте выше.

У этого подхода есть большой плюс, когда несколько разработчиков параллельно работают в разных ветках, и merge-конфликты в чейнджлоге в этом случае исключены. Но после своего релиза я увидел для себя большой минус: из строчки CHANGELOG.md нельзя "прыгнуть" на коммит или pull request, в котором были реализованы указанные изменения, а можно только посмотреть на коммит с самим чейнджлогом:

git blame бессилен

Поэтому я решил просто редактировать CHANGELOG.md по ходу разработки, так что для версии 0.2 и далее чейнджлог в сочетании с историей репозитория будет иметь двойную пользу: и расскажет о сути изменений, и укажет на сами изменения.

git blame счастлив

В общем и целом

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

Из всех проблем меня сейчас больше всего волнует отсутствие тестов, потому что я уже чувствую, что новые изменения совсем скоро начнуть ломать текущую функциональность. Поэтому надо хотя бы бэкендную часть покрыть тестами процентов на 80, чтобы я пореже ловил "Internal Server Error" после покупок в магазине.

Ну и это еще не весь рассказ о том, что сейчас "под капотом", так что напишу еще и про CI, и про инфраструктуру, и про то, как я иногда привлекаю AI для код-ревью, и про то, как недавно всё упало, и еще про что-нибудь.

Stay tuned!