Как стать автором
Обновить

Todo-лист на максималках: разбираем архитектуру крупного приложения

Уровень сложностиСложный
Время на прочтение31 мин
Количество просмотров1.4K

В этой статье я покажу, как устроена многослойная архитектура крупного реактивного web-приложения, и особенности его запуска под Electron. Материал будет полезен, если вы планируете начать свою разработку, хотите попробовать себя в роли архитектора, вас не пугает Shared Workers, Service Workers или, в конце концов, вы хотите это попробовать или разобраться.

Frontend  — это просто, сынок!
Frontend — это просто, сынок!

Что разрабатываем

Персональный планировщик. Тот самый todo-лист, который встречался каждому в начале карьеры как тестовое или учебное задание. Только здесь — на максималках, в духе топовых мировых приложений. Как мы увидим, разница в архитектуре между лабораторной работой и промышленным образцом — как между шалашом и небоскрёбом. Оба вроде про жильё, но есть нюансы…

Итак, SingularityApp. Возможно, кто-то из вас или ваших коллег им пользуется. Вещь получилась годной и довольно популярна в России. Удивительно, но Иран находится на третьем месте по количеству установок. У нас нет локализации на фарси, хотя мы поддерживаем 11 языков. Ну, что есть — то есть, пусть загадка остаётся загадкой.

تکینگی نزدیکتر از چیزی است که فکر می کنید!
تکینگی نزدیکتر از چیزی است که فکر می کنید!

Требования

Выбор технологии всегда должен быть обусловлен требованиями к системе. И в меньшей степени амбициями разработчика “попробовать новую прикольную штуку”, (если это не pet-проект). Какие сейчас у нас есть требования, определившие выбор технологии и архитектуры:

Web-версия и desktop-версия под все платформы: Windows, Linux, MacOS (включая размещение в App Store). Мобильные платформы Android и iOS, а также умные часы реализованы на другом стеке, поэтому здесь мы их не рассматриваем.

Работа в offline. Приложение сохраняет полную функциональность без подключения к интернету, а при восстановлении связи автоматически синхронизирует данные с сервером.

Мультиоконная работа. Пользователь может перемещать задачи между окнами через D&D (drag-and-drop), например, перекидывая их из проектов в календарь. Такой подход удобен для недельного планирования и работы в Split View. Для реализации требуется надёжный механизм синхронизации состояний окон, который работает стабильно и быстро, даже в offline-режиме. Мы используем Shared Worker. 

Календарь ОК, но  в Split View неделю планировать удобнее!
Календарь ОК, но в Split View неделю планировать удобнее!

Time Travel. Приложение позволяет отменять и повторять действия (Redo / Undo) независимо в каждом окне. Но есть нюанс: изменения, которые приходят с сервера (например, от мобильного устройства), отменить нельзя — так что это нужно учитывать при разработке.

Нотификации. Пользователь может настроить несколько уведомлений на каждую задачу. Нам нужно вовремя показывать напоминания, а для этого лучше использовать системный API — он и точнее, и привычнее для пользователя.

Повторяющиеся (рекурсивные) задачи. Пользователь создаёт шаблон, на основе которого формируются повторяющиеся задачи. Генерация таких задач должна происходить на клиенте, чтобы всё работало в offline-режиме. При этом важно учитывать, что пользователь может работать с несколькими устройствами, и синхронизация экземпляров задач должна быть точной и без конфликтов.

Хоткеи. Горячие клавиши должны покрывать максимум сценариев “из коробки”. Однако многие комбинации уже заняты либо другими классическими приложениями, либо зарезервированы операционной системой, либо недоступны в браузере. Назначение горячих клавиш может стать головной болью, если про это задуматься слишком поздно. Например, хоткей быстрого создания задачи должен работать независимо от того, какое приложение открыто.

Прикрепление файлов. Пользователь должен иметь возможность прикреплять файлы или скриншоты прямо к задаче — это базовый сценарий, который нельзя игнорировать. Для хранения можно использовать наше облако или подключить свой S3. На десктопе файлы должны открываться в любимой программе пользователя (например, doc-файл — в Word) и после редактирования синхронизироваться между устройствами. Всё должно работать так же просто и удобно, как в Google или Yandex Диске.

WYSIWYG-редактор для задач и заметок. Как выяснилось, многим удобно вести заметки рядом с задачами. Возможно, это отголосок ушедшего Notion. Пока наш редактор ещё не всё умеет, роадмап расписан на полгода вперед. Форматирование текстов, работа с картинками и таблицами (!) уже реализованы.

Если пользователь вставляет картинку, она должна сохраняться в подключённом S3 и синхронизироваться между устройствами. Копирование задач или картинок между заметками не должно создавать копии файлов — место и трафик нужно расходовать бережно.

Сложность возникает, когда задачи синхронизируются с другими сервисами, такими как Google Calendar, которые не поддерживают полноценное форматирование. Аналогичные проблемы появляются при экспорте и импорте данных из похожих приложений, где функционал работы с форматированием может быть ограничен или отсутствовать.

Возможность делиться своими проектами. Пользователь должен иметь возможность делиться проектами как с другими участниками приложения (например, небольшими командами или фрилансерами), так и через ссылки для тех, у кого нет аккаунта в системе. Иногда простой список дел для строителей оказывается эффективнее, чем шестиминутные голосовые в мессенджерах.

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

Поддержка масштабных баз задач. Десятки или сотни тысяч задач у одного пользователя — это не гипотеза, а реальность. Крайне активные пользователи за несколько лет формируют огромные базы данных. Поэтому LazyLoad и продуманная система кеширования становятся обязательными. Приложение должно работать в offline-режиме, а данные из базы в стейт и компоненты должны подгружаться лениво, чтобы минимизировать нагрузку на память в каждом открытом окне.

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

Нативные интеграции в OS

  1. Для macOS меню приложения должно корректно обновляться, активируя или деактивируя пункты в зависимости от текущего контекста. Например, набор опций в меню изменяется при переключении между WYSIWYG-редактором и списком задач.

  2. Работа в трее. Некоторым пользователям удобно быстро создавать задачи прямо из трея.

  3. Возможность интегрироваться в шторку OS.

  4. Нативные OS-нотификации.

  5. Диплинки. Как на отдельные задачи, так и на проекты.

  6. Покупки внутри приложения. Для macOS должна быть реализована возможность покупок внутри приложения через механизм Apple, чтобы соответствовать требованиям платформы.

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

Автотесты. Юнит-тесты должны покрывать не менее 80% кода, а типовые сценарии обязательно должны быть защищены интеграционными тестами. Это критически важно для долгоиграющего приложения, где риск что-то сломать возрастает с каждой новой фичей. Ручной беглый тест даже по уже известным сценариям занимает до двух человеко-дней, что, конечно, не радует.

Отдельные потоки для тяжелых операций. Интерфейс приложения не должен зависать из-за тяжёлых операций. Импорты из Todoist, Things3, TickTick, OmniFocus, CSV-файлов и других источников, генерация рекурсивных задач, поиск, пересчёт прогресса проектов, синхронизация файлов и многое другое — всё это должно выполняться в отдельных потоках, чтобы пользователь мог работать без задержек.

Поиск в режиме offline. Пользователь должен быстро находить нужные задачи или проекты, используя локальную базу, без обращения к серверу.

Ежедневные локальные резервные копии. Данные пользователей должны ежедневно сохраняться локально. Потеря информации, даже без использования синхронизации, недопустима.

Что еще?

Приложение включает такие функции, как интеграция с календарями (включая двустороннюю), защита задач паролем, фильтры, теги, избранное, приоритеты, цветовые темы, телеграм-бот для постановки задач, распознавание распечаток планов дня и другие. Большинство из них не оказывают значительного влияния на архитектуру (и так уже получился солидных список). Исключение — интеграция с календарями, которая требует реализации двусторонней синхронизации.

Почему Electron

Я выбрал Electron за основу приложения не потому, что его используют Microsoft, Docker, Slack, Discord и другие крупные проекты и компании. Это, конечно, приятно знать, но выбор был продиктован исключительно прагматичными соображениями:

  1. Web-стек хорошо знаком нашей команде — мы занимаемся web-разработкой уже более 20 лет.

  2. Портабельность. Electron работает на всех популярных (и даже не очень) платформах. Конечно, поддержка платформ требует времени: например, нас регулярно просят о сборках под специфичные дистрибутивы Linux. Это не суперзатратно (кроме затрат на ручное тестирование) и при наличии значительного спроса может быть легко реализовано.

  3. Electron позволял реализовать все требования. Веб-приложение, например, не смогло бы редактировать приложенные к задачам файлы в любимой пользовательской программе с последующей синхронизацией. А тут такая возможность есть.

  4. Web-версия. Используя полиморфизм для платформо-зависимых операций, мы можем с минимальными усилиями создать web-версию (PWA: Progressive Web Application).

Из минусов: 

  1. Размер приложения составляет 140 МБ, из которых лишь 20% приходится на собственный код. Остальное — это компоненты Electron, включая движок Chromium и Node.js. Такое решение делает дистрибутив тяжелее по сравнению с нативной разработкой.

  2. Требовательность к памяти. Electron-приложения съедают 500+ МБ оперативной памяти уже на старте — движок Chrome так устроен. Каждое дополнительное окно добавляет ещё 200 МБ+. Для современных машин это не критично, но при интенсивной работе без перезапуска (а это частый сценарий, когда приложение не выключают неделями, а то и месяцами) расход памяти может вырасти до пары гигабайт — и это уже ой.

  3. Производительность ниже нативной. Движок Chromium, на котором построен Electron, очень шустрый, но всё же уступает нативным решениям. В нашем приложении нет тяжёлой математики, за исключением случаев вроде импорта задач из других планировщиков, ресайза изображений или ситуаций, когда пользователь решает героически перетащить тысячи задач через D&D между окнами.

  4. Ограниченная поддержка старых ОС: Начиная с версии 23, Electron, (вслед за Microsoft и Chrome) прекратил поддержку Windows 7, 8 и 8.1, что может ограничить доступность приложения для пользователей с устаревшими системами. Тут они конечно не правы, но у нас осталась рабочая web-версия.

В целом минусы больше похожи на приемлемый компромисс, особенно при современном железе. Мобилка и часики, повторюсь, разрабатываются отдельно.

Верхнеуровневая архитектура

В отличие от веб-приложений, приложения на Electron состоят из трёх основных процессов:

  1. Main (основной процесс). Запускается первым и управляет жизненным циклом приложения. Имеет полный доступ к системным ресурсам, включая файловую систему и нативные API. Отвечает за создание окон, системные меню, обработку диплинков, платежи (например, для macOS) и резервное копирование. Код, написанный для основного процесса, не будет работать в PWA-версии.

  2. Renderer (процесс рендеринга). Отвечает за отображение пользовательского интерфейса. По сути, это окно браузера Chrome, в котором выполняется JavaScript, HTML и CSS. Код, написанный здесь, можно использовать в PWA.

  3. Preload. Специальный слой, который загружается перед основным содержимым окна. Он обеспечивает безопасный мост между основным и рендерер-процессами, позволяя ограниченно использовать Node.js API в рендерере. Это повышает безопасность, предотвращая несанкционированный доступ к системным ресурсам из рендерера. 

Такая архитектура чётко разделяет обязанности между процессами, что улучшает безопасность и стабильность приложения. Однако, если приложение использует API из слоя Main, но должно полноценно работать в PWA, необходимо предусмотреть две реализации этого API с использованием полиморфизма в зависимости от платформы сборки.

Пример: открытие внешних ссылок. В web-версии достаточно просто открыть ссылку в браузере, а в Electron-версии потребуется отправить запрос в Main, чтобы он открыл браузер и перенаправил на нужный URL.

Кроме того, некоторые операции зависят от конкретной платформы. Например, для соответствия требованиям App Store в macOS необходимо использовать их платежный API, который отсутствует на других платформах.

Как сделали: весь платформо-зависимый код размещен в отдельной папке

/provider/electron — код для electron
/provider/web — код для web
/provider/base — общие интерфейсы платформо-зависимого кода

Далее. Используем разные tsconfig для разных платформ. Например tsconfig.win.json

{

    "extends": "./tsconfig.json",

    "compilerOptions": {

        "paths": {

…

            "@provider/*": [ "./provider/electron/*" ],

        }

    },

…

}

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

Такой подход упрощает тестирование, так как реализации интерфейсов можно изолировать и заменять моками (поддельными объектами, имитирующими поведение реальных компонентов), а также делает код более гибким и расширяемым при добавлении новых платформ. Полиморфизм в данном случае позволяет обеспечить прозрачную замену реализации без необходимости вносить изменения в остальной код, который остается независимым от платформенной специфики.

Shared Workers

Все окна приложения могут взаимодействовать через общие части, вынесенные в Shared Workers. Эти фоновые процессы браузера напоминают невидимые окна с рядом ограничений, но позволяют централизовать данные и логику, доступные всем окнам.

К сожалению, “из коробки” работа с Shared Workers неудобная: взаимодействие основано на отправке сообщений между окнами. В сложных приложениях это может превратиться в настоящий хаос сообщений. В этой статье я подробно показал, как избавить (вернее, красиво изолировать) отправку сообщений и использовать привычные промисы или сахар async / await.

Совет: Архитектура на событиях — вещь хрупкая. Использование типизированных контрактов и интерфейсов вместо разрозненных нетипизированных событий поможет сэкономить время и нервы.

У нас по-прежнему есть некоторые события, отправляемые через сообщения (например, ON_MIDNIGHT, чтобы сообщить подписчикам о наступлении полуночи, или ON_PATCH, для уведомлений об изменениях в базе данных). Однако их немного, они тщательно документированы, а транспортный слой спроектирован так, чтобы минимизировать возможность ошибок.

Нюансы Electron #1: Вам сразу нужен кастомный протокол! 

Если вы хотите использовать Shared Workers в Electron так же, как в веб-приложении, вас ждёт сюрприз: это не сработает. Точнее, сработает, но не так, как вы ожидаете.

В вебе Shared Worker запускается один раз, и все окна могут к нему обращаться, обеспечивая общее состояние. В Electron, если работать “из коробки”, каждый Shared Worker будет запускаться как обычный Worker, и каждое окно создаст свою копию. В результате взаимодействие между окнами просто развалится.

Проблема кроется в протоколе. Shared Workers корректно работают только если приложение запущено по протоколу вроде https://. Electron же, для простоты и скорости, использует file:// — и вот тут Shared Workers превращаются в тыкву (точнее, в обычные Worker).

Решение:

Для корректной работы Shared Workers необходимо использовать кастомный протокол, который будет работать так же, как https://. Это немного сложнее в настройке, но в итоге позволяет сохранить общее состояние между окнами.

protocol.registerSchemesAsPrivileged([
   {
       scheme: SINGULARITY_HTTP_PROTOCOL,
       privileges: {
           standard: true,
           secure: true,
           allowServiceWorkers: true,
           supportFetchAPI: true,
       },
   },
]);

export function registerAppProtocol() {
   protocol.handle(
       SINGULARITY_HTTP_PROTOCOL,
       request => {
           const path = convertSgUrlToFilePath(request.url);
           return net.fetch(`file://${path}`);
       },
   );
}

if (app.isReady()) {
   registerAppProtocol();
} else {
   app.on('ready', () => registerAppProtocol());
}

Нюаннасы Electron #2: Меняете протокол — потеряете доступ к базе. 

Если вы решили перейти на кастомный протокол, чтобы запустить Shared Workers, будьте готовы к неприятному сюрпризу: вы потеряете доступ к базе данных, если она хранилась в IndexedDB. Это связано с особенностями работы браузерного API: протокол учитывается при доступе к IndexedDB.

Например, если ваша база данных была создана под file://, она станет недоступной после перехода на кастомный протокол вроде app://. Просто скопировать файлы базы из одной системной папки в другую тоже не получится — судя по всему, Chrome использует защиту, предотвращающую подобные манипуляции.

Наш опыт:
Между 4-й и 5-й версией приложения (сейчас мы на 8-й) пришлось реализовать хитрую миграцию. Сначала мы создали механизм резервного копирования базы данных из старого хранилища file://, а затем написали код для переноса данных в новое хранилище sg://.

Вывод:
Если вы используете IndexedDB в Electron и планируете менять протокол, обязательно предусмотрите механизм миграции. Это избавит вас от проблем с доступом к существующей базе и сохранит данные пользователей. Игнорировать эту особенность нельзя: данные в IndexedDB завязаны на протокол, и при его смене доступ к ним пропадает.

Нюансы Electron #3: Как попасть из Main в Shared Worker. 

Если вам нужно (а нам — нужно) вызывать из Main какие-либо API, предоставляемые Shared Workers, увы и ах, напрямую сделать этого не получится. Единственный способ — пробрасывать вызовы через окно.

Два подхода:

  1. Использовать любое открытое окно. Можно научить любое активное окно проксировать вызовы из Main в Shared Worker. Однако у этого подхода есть очевидный недостаток: пользователь может закрыть окно в любой момент, и доступ к Shared Worker будет потерян, что чревато прерыванием операций.

  2. Создать отдельное proxy-окно. Это невидимое окно, которое работает в фоновом режиме и предназначено исключительно для проксирования вызовов из Main в Shared Worker. Такой подход решает проблему закрытия окон пользователем, обеспечивая стабильный доступ к общим данным.

Я сразу остановился на втором варианте, так как этот код не нужен в PWA, а наличие отдельного proxy-окна устраняет риски остаться без доступа к базе посреди выполнения операции.

Далее рассмотрим типы воркеров, которые используются в нашем приложении.

Работа с базой

db.worker — самое ядро ядра. Отвечает за взаимодействие с базой данных. Его главная задача: при любых изменениях в базе оповестить подписчиков об этом изменении. 

  • Пользователь создаёт задачу в одном из окон, задаёт время и добавляет уведомления.

  • Задача записывается в базу, а db.worker уведомляет все окна, которые обновляют свои состояния.

  • Параллельно другие воркеры выполняют свои задачи: обновляют поисковый индекс или отправляют данные на сервер.

В качестве движка базы мы используем встроенную в браузер NoSQL-базу IndexedDB. Она довольно шустрая. Кроме того, используем обвязку Dexie, которая изолирует и делает простым и понятным код работы с базой. В итоге мы работаем с довольно простыми моделями.

export class HabitModel extends BaseModel<HabitModel> {
   static defaults = Defaults;
   static schema = new ModelDbSchema({
       collection: DbCollection.HABITS,
       index: new VersionedIndex()
           .add(SchemaVersion.v6, '&id,[id+_removed],_removed,[_removed+status]'),
       keyPrefix: 'HB',
       relations: new Relations()
           .add(DbCollection.HABIT_DAILY_PROGRESS, 'habit'),
   });

   title: string
   description?: string;
   color?: string;
   status: HabitStatus;
   order?: number;

   constructor(initialState: Partial<HabitModel> = Defaults) {
       super(initialState, Defaults);
   }
}

export type HabitFields = ExcludeMethods<HabitModel>

export type DbPatchReplaceHabit = DbPatchReplaceAny<HabitFields, DbCollection.HABITS>;

Дополнительные задачи db.worker:

  1. Миграция базы на старте приложения. При запуске приложения выполняются миграции: добавляются новые таблицы, изменяются поля или переносятся данные.

  2. Каскадное удаление. Например, при удалении задачи необходимо пометить связанные сущности (чек-лист, файлы, заметки) как удалённые.

  3. Работа со связями. Управление связями между сущностями, обеспечивающее целостность данных.

  4. Логическое удаление. Удаление записей происходит через установку флага _removed. Это необходимо для последующей синхронизации изменений с сервером. Физическое удаление осуществляется только после подтверждения сервером или при ручной очистке базы пользователем.

Нюансы:

  1. В Dexie есть свой плагин, обеспечивающий мультиоконную синхронизацию и Live-режим, но на мой взгляд он сделан довольно топорно: использует дополнительные таблицы для синхронизации. Мы его не используем.

  2. IndexedDB может работать в 2 режимах: персистентный и неперсистентный (по умолчанию). С практической точки зрения “неперсистентный” значит, что браузер может удалить всю вашу базу, без объявления войны, просто потому, что “пора почистить место”. Персистентный режим гарантирует, что база не будет удаляться. Но очень неспроста многие приложения (вроде того же Telegram) мониторят доступное дисковое пространство и делают локальные резервные копии. С пользовательскими данными нужно быть очень осторожными, иначе вас сожгут в аду.

Замечу, что IndexedDB не представляет проблему в автотестах, так как есть готовый эмулятор на JS.

Синхронизация файлов

file.worker — обеспечивает синхронизацию файлов с S3-сервером (нашим или заданным пользователем). Мы изначально предусмотрели полиморфный драйвер, поддерживающий несколько протоколов, таких как FTP, но в публичной версии оставили только S3. И тому были свои причины: 

  1. Мы сами используем именно S3. Это де-факто стандарт.

  2. Множество хостеров предоставляют S3 по хорошей цене.

  3. Поддерживать несколько протоколов трудоемко, тем более — отвечать на вопросы пользователей с подключениями (а их будет масса, и даже не надейтесь, что документация поможет: никто ничего не читает).

  4. Тестировать на регрессии становится экспоненциально сложнее с добавлением каждого нового протокола. 

Нюансы: 

  1. Повторные попытки. Все операции по отправке и загрузке файлов должны обязательно предусматривать повторные попытки. Нет никакой гарантии, что файл благополучно скачается. Или что на сервере хватит места. Или что пока заливался файл другой клиент (например, мобильное приложение) место не израсходовал, хотя оно изначально было. 

  2. AbortController. Изучите, как работает AbortController, это понадобится на случай, если вы синхронизируете файлы, а пользователь посреди процесса заменил настройки. Нужно уметь аккуратно прервать текущую сетевую операцию.

  3. Предусмотрите шейпер. Отправка больших файлов может сильно нагружать CPU и расходовать батарею, особенно при повторных попытках. Если загрузка не удалась, интенсивность повторов нужно снижать, чтобы избежать негативной реакции пользователей. Однако полностью отказываться от повторов нельзя — место на сервере может внезапно появиться.

  4. Тщательно логируйте и обрабатывайте ошибки.

  5. Тестирование — сложное. Для автоматического тестирования синхронизации требуется мощная инфраструктура. S3 ещё можно эмулировать локально, но это потребует поднятия отдельных портов, что затрудняет параллельный запуск тестов и вынуждает использовать флаг --runInBand.

С проприетарными и менее популярными протоколами ситуация еще сложнее: без доступа к реальной инфраструктуре их полноценное тестирование практически невозможно, что резко увеличивает сложность и затраты на поддержку.

  1. Обязательно отделяйте метаданные файлов (например, название, статус синхронизации) от их содержимого. Причина проста: IndexedDB возвращает записи целиком, а не отдельные поля. Если требуется вывести только список прикреплённых файлов, считывать их содержимое нет смысла — это затратно по памяти и времени.

Хранить сами файлы в базе можно (так, например, делает Figma), и в PWA это зачастую единственный вариант. Однако создаётся риск засорения базы. Здесь важно найти баланс между поддержкой офлайн-режима и размером пользовательского кэша.

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

Я оставлю за скобками алгоритмы определения, где какой файл новее, или алгоритм переноса данных из одного S3-аккаунта в другой, когда пользователь меняет настройки. Там много мелких нюансов, но они все преодолимы и не касаются архитектуры.

Однако есть аспект, который стоит учитывать сразу: имитация симлинков. Допустим, пользователь прикрепил файл к задаче и затем сделал 10 её копий. Нужно ли дублировать содержимое файла? Конечно, нет: это мгновенно увеличит размер базы данных и создаст избыточный трафик.

Решение — вместо копий использовать механизм, аналогичный Symlink в Linux. Это позволяет ссылаться на оригинал файла, избегая дублирования данных.

Совет:

Для реализации симлинков удобно использовать JavaScript-объект Proxy. Он позволяет перехватывать операции с объектами и гибко управлять их поведением.

Для desktop-версии файлы можно сохранять на файловую систему. Далее организовать их watcher, что бы после редакрирования в любимой программе выполнить обратную синхронизацию. Тут нужно аккуратно с правами и таймаутами

Уведомления

notification.worker отвечает за генерацию уведомлений по поставленным задачам. 

Стратегия: на старте анализируем задачи с установленными уведомлениями. Определяем ближайшее по времени событие, засыпаем до его наступления. В нужный момент просыпаемся и уведомляем подписчиков:

  • Main подписывается на эти события, чтобы показать нативные уведомления.

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

Пока "спим", параллельно отслеживаем изменения в базе данных (патчи). Если что-то изменилось — перестраиваем список уведомлений.

Ряд уведомлений не связан с задачами (например, завершение пробного периода или истечение подписки). Механизм их обработки аналогичен, но триггер события другой.

Нюансы: 

Интуитивно кажется, что можно использовать setTimeout для ожидания события, но длительные таймеры ненадежны. Если пользователь отправляет устройство в гибернацию (например, закрывает крышку ноутбука) и включает его через несколько часов, таймеры могут сработать некорректно. Такие ситуации можно отследить и учесть, но это требует дополнительных усилий.

Ещё хуже, если пользователь изменяет системное время вручную. При работе в offline-режиме такие сценарии часто оказываются сложными для обработки, и гарантировать полную корректность работы системы уведомлений становится почти невозможно.

Поиск. Фильтры. Прогрессы проектов.


search.worker
— отдельный поток, отвечающий за поиск и работу фильтра. По задачам и проектам нам нужен поисковый индекс. Более-менее качественно задачу позволет решить движок flexsearch, в итоге остановились на нём. На старте мы обновляем поисковый индекс и затем следим за патчами в базу: если патч касается интересующих нас сущностей (задачи, проекты, заметки, чеклисты) — обновляем индекс.

В этот же воркер вынесена сложная логика работы фильтров, где пользователь может “натыкать”, какие проекты, задачи и по какому принципу он хочет отфильтровать. Операция поиска здесь ресурсоемкая, поэтому, чтобы не было фризов интерфейса, вынесена в worker.

Так же сюда попали пара сервисных методов, вроде отслеживания прогресса по проектам (чекаешь задачу — закрашивается кружок): уж больно красиво ложилось на архитектуру этого worker, а в Renderer это получалось дорогой операцией.

Синхронизация с облаками

cloud.worker отвечает за синхронизацию пользовательских данных с сервером. В его зоне ответственности также находится синхронизация с календарями и почти весь код для авторизации и регистрации пользователей. Исключение составляет OAuth (авторизация через Google, Apple, Yandex, VK и т.д.), где часть логики распределена между renderer, main и сервером.

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

Мы используем несколько серверов для обеспечения отказоустойчивости. cloud.worker определяет самый быстрый и работает с ним. К этому пришли не сразу, спасибо РНК, который своими рандомными блокировками мешал нормально работать. 

Так получилось, что на сервере для общения микросервисов мы используем gRPC. Это стандартное решение: бинарный протокол с четкой схемой хорошо справляется с обработкой запросов от клиентов разных версий. Однако прямой поддержки gRPC в браузерах нет. Не беда, берем gRPC web. И выясняем, что worker вырос на 2-4 мегабайта. Упс. Во всем виноваты болтливые модели, полученные из protobuf. Чтобы не раздувать другие места приложения, нужен конвертор моделей из gRPC в те, которые у нас в базе данных.

Совет: вообще за изоляцией кода между worker / renderer и main нужно следить. Один неловкий import, и половина кода из renderer перекочевывает в main, или размер worker увеличивается в 5 раз. Можно сделать такую изоляцию на уровне репозиториев или разбить на разные пакеты. У меня руки дошли только до правил для husky, который изо всех сил будет проверять законность импортов и не давать коммитить дичь.

Планы на будущее: транспорт планируется перевести на web-сокеты и пуш-уведомления вместо регулярного опроса сервера.

Кроме того, реализована концепция Feature Sync: если в новой версии приложения появляется новая сущность (например, заметки или привычки), cloud.worker способен получить с сервера только данные, относящиеся к этой фиче. Это позволяет сократить трафик при крупных обновлениях и избежать загрузки всей базы.

Работа по расписанию

Cron.worker — исторически cron.worker отвечает за выполнение методов, которые работают по расписанию. Основная задача этого модуля — генерация рекурсивных задач, одна из самых сложных частей приложения.

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

Принципиально есть несколько схем при работе с повторяющимися задачами.

  1. Генерация только ближайшего экземпляра по шаблону. Это рабочий метод, с которым мы жили несколько лет до появления полноценного календаря в приложении. Например, Things 3 до сих пор использует эту схему, и она неплохо работает. Однако, как только появился календарь, стало очевидно, что пользователям хочется видеть повторяющиеся задачи в будущем.

  2. Использование RRULE. RRULE — это стандарт iCalendar для описания повторяющихся событий (например, "каждый понедельник в 9 утра").

    1. Плюсы: экземпляры не создаются, пока они не нужны, что экономит ресурсы.

    2. Минусы: генерация экземпляров происходит на стороне renderer, что может приводить к фризам. Несмотря на перспективность, этот подход оказался сложным для реализации.

  3. Генерация будущих экземпляров в ограниченном количестве. Мы остановились на этом варианте из-за его относительной простоты. Задачи генерируются на определенный период вперёд (максимум на год). Да, пользователь не увидит задачи на два года вперёд, но таких жалоб пока не было.

Сложности реализации:

  1. Изменение задач и шаблонов: когда пользователь редактирует задачу, созданную по шаблону, а затем меняет сам шаблон, возникает дилемма:

    • Перегенерировать задачу по новому шаблону?

    • Оставить задачу в текущем состоянии?

    • Если изменяется экземпляр задачи (например, описание или чек-лист), как это отразится на шаблоне?

    • Или каждый раз спрашивать у пользователя “Что вы хотите сделать?”, как это делает Google Calendar?

Здесь нет универсального решения (все плохие). Но какой-то компромисс найти придется.

  1. Работа в offline: генерация задач должна быть предсказуемой, чтобы синхронизация работала корректно. Это включает:

    • Использование одинаковых ID и тайм-штампов.

    • Обработку редких, но неприятных кейсов. Например: пользователь включает старое устройство через два года, и оно генерирует 100 задач по уже удалённому шаблону.

Если бы механизм разрабатывали с нуля, использование RRULE выглядело бы предпочтительнее. Но переписывать текущую реализацию затратно, а разницу в работе, скорее всего, никто не заметит.

cron.worker — это сложный, но жизненно важный компонент для работы с повторяющимися задачами. Решения, принятые в его архитектуре, базируются на реальных потребностях пользователей и компромиссах между удобством, производительностью и сложностью реализации.

Shared Workers: взаимодействие и нюансы

Shared Workers способны общаться друг с другом, что делает их идеальными для координации задач. Например:

  • Почти все воркеры обращаются к базе данных через db.worker.

  • file.worker чистит кэши по расписанию, задаваемому cron.worker, и реагирует на события авторизации из cloud.worker.

Сложные сценарии: переавторизация и очистка базы

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

Оптимизация выборок из базы и межпроцессных взаимодействий

Для повышения производительности мы разрешили worker'ам и окнам напрямую читать данные из базы, вместо того чтобы отправлять межпроцессные запросы через db.worker. Межпроцессные запросы могут быть дорогими, особенно при передаче больших объемов данных. Прямой доступ к базе упрощает работу с данными и снижает нагрузку на db.worker. Однако операции записи выполняются централизовано и в транзакциях.

service.worker

Сервис-воркеры — это инструмент, позволяющий перехватывать запросы от приложения к серверу и добавлять к ним собственную логику обработки. Чаще всего они используются для реализации стратегий кеширования.

Примеры типовых стратегий:

  • Cache First: сначала проверяется кэш, и только если нужного ресурса там нет — идёт запрос к серверу.

  • Network First: запрос отправляется на сервер, а если он недоступен, используется кэш.

  • Stale While Revalidate: пользователь получает данные из кэша, а в фоне отправляется запрос на обновление ресурса.

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

В нашем случае service.worker оказался полезным для обработки запросов к файлам, прикреплённым к задачам:

  • Если запрашивается файл (например, картинка), сначала проверяем, есть ли он в локальной базе.

  • Если файл в базе найден, он немедленно возвращается пользователю.

  • Если файла нет (например, он слишком большой для хранения в базе), он загружается с S3-сервера в фоне, после чего возвращается пользователю.

Совет: Service Worker имеет хитрый жизненный цикл с тонкостями инициализации и деинициализации. Используйте его только там, где это действительно необходимо. Для большинства задач отлично подходят стандартные заголовки кеширования. Но если нужен гибкий и нетиповой подход, будьте готовы к сложной отладке.

Нюанс Electron: Изначально Service Worker появился только в PWA-версии приложения, так как в версии Electron он был не нужен: файлы на Desktop можно было скачивать напрямую. Однако, после добавления в заметки возможности вставлять картинки, необходимость в Service Worker появилась и в Desktop-версии.

После внедрения Service Worker тесты начали падать в самых неожиданных местах, хотя ручная проверка не выявляла проблем. Причина оказалась в особенностях Electron: он жёстко кеширует Service Worker в своих системных папках. Это привело к "магии вне Хогвартса" — непредсказуемым ошибкам между тестовыми запусками.

Первоначальным решением стала очистка кешей Electron перед каждым тестом. Однако основная ошибка была в некорректной повторной инициализации Service Worker. После исправления проблемы тесты стабилизировались, но процесс поиска ошибки отнял много времени и нервов.

Service Worker — мощный инструмент для сложных сценариев, таких как гибкое управление кешированием или обработка запросов к локальным файлам. Однако его использование требует внимательного подхода к отладке, особенно в специфичных средах вроде Electron.

Renderer

Renderer — это слой Electron, знакомый каждому web-разработчику. По сути, это окно браузера с дополнительными API, которые пробрасываются из Main через Preload.

Само приложение построено по классической схеме:

  • React — классика и стандарт отрасли. 

Почему не Vue, например?

На момент старта разработки Vue не был популярен, но и сейчас, выбирая между Vue и React в подобных проектах, в долгосрочной перспективе я склоняюсь к React. Причина: React больше заботится об обратной совместимости а Vue — нет (как это было при переходе со 2 на 3 версию). Если для сайтов это не так критично, то в долгоиграющем проекте это может быть пробемой. Хотя оба фреймворка достойные.

  • Менеджер состояний. О нем немного позже.

  • Компоненты. Изначально писались без какого-то жесткого каркаса. Однако по мере роста приложения пришлось их реструктурировать. В итоге получился подход, крайне похожий на Feature-Sliced Design (FSD). Мы не используем FSD в чистом виде (все же он несколько громоздок), но к схожим идеям мы пришли сами, просто по мере развития приложения.
    Для небольших проектов нет смысла усложнять архитектуру компонентов на старте. Всё равно идеальной архитектуры вы не получите. Лучше иметь работающий код и постоянно его рефакторить по мере развития проекта.

  • Тулзы и хелперы. В любом проекте со временем накапливается утилитарный код, не относящийся напрямую к компонентам или стору. Например, локализация и работа с переводами или телеметрия.

Однако у меня аллергия на термины вроде tools или helpers. Часто это выглядит как свалка функций под красивым названием, чтобы обойти архитектурные решения. “Не знаю, как организовать это красиво, поэтому сложу сюда и назову helper” — так это обычно воспринимается на код-ревью.

Сейчас такие "свалки" в проекте появляются редко. Все утилиты стараемся оформлять так, чтобы они были изолированными (минимум зависимостей) и могли быть вынесены в отдельный пакет. Тем не менее, в проекте всё ещё остались старые "помогалки", которые не прошли рефакторинг. Но мы постепенно от них избавляемся, делая код чище и понятнее.

Менеджер состояний

Теперь немного о менеджере состояний. Вопрос его выбора — крайне холиварный. Однако я настоятельно рекомендую изучить это исследование. С момента публикации материала прошло 2 года, вероятно часть озвученных автором проблем уже исправлена (будем надеяться, потому что часть стейт-менеджеров были просто сломаны) однако сам подход, с которым автор подошел к бенчмарку, заслуживает уважения.

На старте разработки мы остановились на Redux, который тогда был стандартом в индустрии. Однако MobX всегда манил своей лаконичностью, аккуратным синтаксисом и удобной реактивностью через observer. Чтобы протестировать подход, мы реализовали изолированный экран для менеджера привычек с использованием MST.

MST (MobX State Tree) построен на базе MobX, но добавляет жёсткую структуру для моделей и экшенов. Это делает его отличным выбором для крупных проектов, хотя и с большим потреблением памяти. MobX и MST работают вместе без проблем, позволяя использовать их комбинацию там, где это уместно.

Да, у нас появились два менеджера состояний в приложении, но данные между ними не пересекались, что позволило нам протестировать инфраструктуру на практике.

Миграция с Redux на MST: боль и уроки

Это было очень больно! Дедушка Фаулер назвал бы такой рефакторинг “замена алгоритма”, но русское слово на букву “П” более четко характеризует процесс. Представьте себе бросок лома в унитаз на полном ходу поезда — вот так это ощущается, но длится полгода.

Как действовали.

  1. Покрыли Redux unit-тестами: мы протестировали все экшены и компоненты, добившись покрытия в 80-100%. Основной упор был на snapshot-тесты, где фиксировались входные и выходные состояния.

  2. Создали модели MST с идентичными экшенами: сначала эти экшены были заглушками, вызывающими Redux-код. Это позволило переключить компоненты на новый стор и убедиться, что тесты все еще проходят.

  3. Постепенно переносили экшены: код экшенов из Redux последовательно переносился в MST с постоянной проверкой тестов.

  4. Рефакторинг экшенов: уже после завершения миграции экшены были оптимизированы и структурированы. Тесты помогли не только избежать ошибок, но и исправить старые баги.

  5. Оптимизация: мы уменьшили размер стора на старте, добавили LazyLoad, переписали тяжёлые участки кода.

В итоге приложение стало шустрее в разы и стало потреблять значительно меньше памяти. Тем не менее, любая вкладка браузера, любое Electron-приложение — это минимум 200Mb, а то и 500. 


В нашем случае остался один кейс, где в окно приложения загружается почти вся база. Это экран фильтров, когда пользователь намеренно хочет вывести вообще все свои задачи из всех проектов. Казалось бы, странный кейс, но как показали пользовательские интервью и отзывы, некоторые люди так делают. И ломать этот сценарий нельзя. Можно (и нужно) предусмотреть партиционный рендеринг, но стейт будет жирным.

Имейте в виду, что любой стейт менеджер дает накладные расходы по памяти. В случае с MobX / MST это известная проблема

Да, 100К объектов в сторе — это скорее курьез. Но рано или поздно за памятью придется следить, и в крупных приложениях, которые пользователи не выключают месяцами (ровно как и не перезагружают вкладку браузера), это может быть проблемой. Не могу сказать, что она требует обдумывания на старте разработки — там хватает других забот, да и неизвестно, взлетит ли продукт, но уметь ее решать — надо.

Многооконность

Любой продукт — это всегда компромисс между памятью и производительностью. Яркий тому пример — нативные окна приложения. Изначально у нас было много нативных окон и слой, инкапсулирующий их работу в PWA и превращий в окна на HTML. Сейчас мы избавились почти от всех нативных окон, оставив минимум, типа таймера Pomodoro, или Окна быстрого создания задач, или Окна в трее.

Причина проста:

  1. Время инициализации: Открытие нового окна требует времени для запуска и загрузки состояния. Это ухудшает пользовательский опыт.

  2. Память: Каждое окно "съедает" около 200 МБ памяти. Если таких окон пять, это уже ощутимая нагрузка.

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

  • Замедляет запуск (хотя прогрев можно отложить после появления главного окна).

  • Всё равно потребляет память, даже если окна не используются.

Electron-приложения несколько проигрывают в производительности и экономии памяти нативным аналогам. Это плата за кросс-платформенность. Однако разумный компромисс можно найти, оптимизируя архитектуру и минимизируя количество окон.

Как подружить стейт, многооконность, LazyLoad и Time Travel

Особенностью современных desktop-приложений, к которой многие привыкли, является возможность отмены действий (Redo/Undo). Реализуется это с помощью истории изменений, в которой либо сохраняются снимки состояний (антипаттерн, так как это требует много памяти), либо используются прямые и обратные патчи, которые можно последовательно применять для изменения состояния. MST предоставляет пример реализации таких механизмов, который подходит для простых случаев.

В многооконной архитектуре возникает вопрос:

  • Хранить историю изменений глобально для всего приложения?

  • Вести отдельную историю для каждого окна?

  • Или даже для каждого роута (как в Google Таблицах, где история изменений ведётся отдельно для каждой вкладки таблицы)?

Я остановился на раздельной истории для каждого окна.

Итак, пользователь выполняет какое-то действие. Меняется состояние. Создается патч (прямой и реверсный). Патч попадает в историю и отправляется в базу данных. Другие окна получают уведомление, что в базе что-то изменилось и обновляют свои состояния. Всё?

Нет, не всё. У нас есть LazyLoad и часть данных может просто отсутствовать в сторе. Например, пользователь удаляет задачу — мы должны удалить привязанные к ней чеклисты, заметки и файлы. В стейте на момент удаления их может не быть. Поэтому за консистентность связей у нас отвечает db.worker. Собсвенно, в моделях прописано, какие таблицы зависят друг от друга, а алгоритм удаления отслеживает такие связи и зависимости.

Всё?

Нет, не всё. Получается, что, как выглядит прямой и реверсный патч, мы узнаём в некоторых случаях только после того, как завершится транзакция в базе и db.worker отдаст результат наружу. В историю (Time Travel) нам нужно сохранять не тот патч, который получился на основе изменения состояния, а тот, который получился на основе изменения базы. Это тоже не супер-проблема, просто нужно понимать, в какой момент патчи готовы, а в какой — это просто заготовка.

Всё?

Тоже нет! В некоторых случаях потребуется продвинутая бизнес-логика на стороне db.worker. Например, при удалении файла, привязанного к задаче, нам нужно отследить, остались ли ссылки на этот файл. И удалять его только в том случае, если удаляется последняя ссылка. А значит, в базе могут быть дополнительные запросы и обработчики в рамках отправки одного патча. Таким образом в worker нужно организовать что-то типа механизма сессий, чтобы результирующие патчи от базы не перемешивались, когда два окна почти одновременно чего-то меняют в базе. Сложное место.

Всё?

Нет. Одно окно может поменять что-то, что уже меняло другое окно. А историю операций мы храним раздельно для каждого окна. Соответственно, нужно уметь “подчищать” историю, чтобы не было конфликтов. Понадобится анализатор патчей.

Всё?

Тоже нет. В некоторых случаях необходимо группировать разные экшены к стору в один патч. Пример: редактор задачи должен записывать данные в базу атомарно, а не при изменении каждого поля. В лоб в  MobX/MST решить это не получится (либо нужен отдельный стор для редактирования). Значит, нужен механизм, похожий на “транзакции” — объединяющий патчи нескольких экшенов.

Всё?

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

Вот теперь, пожалуй, всё.

Что еще интересного

Логирование

Время от времени пользователи сообщают о каких-либо проблемах и нам нужны логи. В PWA единственное место, куда мы можем их складировать — это отдельная база данных в IndexedDB. В Electron слой main может записывать логи в файл, слой Renderer — в консоль, в Shared Workers толком никуда (у них даже в консоль не так-то просто попасть). 

Наш модуль логирования складывает логи в базу раздельно по всем окнам приложения. В Electron-версии логи из базы периодически сбрасываются в файл, а база подчищается. Логи на файловой системе ротируются.

Для логирования мы перекрыли стандартные методы console.log / debug / info … Степень “болтливости” логов зависит от того, DEV-билд это или PROD, хотя есть возможность в PROD-билде повысить детализированность логирования.

В общем и целом, система логирования — это важная часть долгоиграющих проектов, к ней надо подходить с умом.

Автотесты

Большое приложение я сейчас плохо представляю без автотестов. Однако среди подавляющего большинства web-разработчиков сайтов нет развитой культуры автоматического тестирования. 

Факт. На сайтах крайне редко пишут автотесты. Причина в их дороговизне (+20-50% к разработке и поддержке). Тем не менее, в крупных web-приложениях такую культуру придется прививать (пусть даже и через “не хочу / не умею / не буду — надо!”). Пусть по началу и не TDD (это простительно на первых порах), но следить за степенью покрытия тестами необходимо на каждом code review. Сейчас мы стабильно держим степень покрытия 80-90%, что является хорошей практикой.

Стандарты кодирования

Основной код-стайл гайд, который используем — берем от Google

https://google.github.io/styleguide/tsguide.html

Дополнительные ссылки, которые следует изучить, по культуре хорошего кода

https://github.com/google/styleguide

https://habr.com/ru/post/653207/

https://github.com/maksugr/clean-code-javascript

На данный момент и соблюдение code style и наличие JSDoc на каждый класс / метод у нас является обязательным, равно как и покрытие тестами. И если в коротких проектах (сдал заказчику и забыл) ещё можно пойти на сделку с совестью, в долгоиграющих проектах — нет. Код в первую очередь должен быть читаемый, понятный и документированный. Иначе разработчики создадут памятник самому себе. Проходили, знаем.

Команде хорошо помогает регулярный code review, обратная связь и разбор образцов: это вот плохо потому-то, а правильно было бы сделать так-то. Со временем в проекте накопилась база примеров плохих и хороших подходов. В эту базу стремно попасть, поэтому есть стимул делать хорошо.

Такое точно не пройдет code review. Ибо оно — такое...
Такое точно не пройдет code review. Ибо оно — такое...

Итоги

  • Разработка крупных, долгоиграющих приложений сильно отличается от лабораторных “на коленке” или тестовых заданий. Даже если берем такую избитую тему, как todo-лист (или графический редактор, например). Требуется взвешенный подход, автотесты, документация, модульность. А цена ошибки в архитектуре будет крайне высокой.

  • Идеальная архитектура с первого раза не получится. Пишите тесты и не брезгуйте регулярным рефакторингом.

  • Electron — отличное решение, если нужно развернуть приложение и как web-версию, и как desktop-приложение под все платформы с размещением в сторах. Но у него есть свои нюансы: накладные расходы Chromium и особенности работы, которые нужно учитывать.

  • Фишки, вроде горячих клавиш, Time Travel, многооконности, мультиязычности и работы в offline во многом определяют архитектуру приложения. Там, где достаточно “просто тыкать мышкой и перегружать страницы по ajax”, справится и заурядный фронтендер. Но пользователи избалованы хорошим софтом, поэтому квест разработки становится кратно сложнее.

  • Обновление приложений и поддержка нескольких версий клиентов — тоже квест, о котором не думают обычные разработчики сайтов. “Перезагрузи страницу” или  “почисти кеш” — так себе стратегия. В долгоиграющем проекте иногда до 10% усилий приходится на поддержку обратной совместимости.

За рамками этой статьи остались работа микросервисов на стороне сервера, архитектура мобильной версии, виджетов и часиков. Отмечу только, что мы совершенно сознательно новые фичи разрабатываем для одной платформы и затем портируем в другую. Возможно расскажу в другой раз, материал и так получился запредельно большой.

Если вам понравился материал — пожалуйста поддержите лайком. Такие статьи пишутся трудно и долго (два раза по все выходные), а ваш лайк мотивирует не опускать руки на середине. Успехов!

Об авторе 

Меня зовут Владимир Завертайлов, основатель SingularityApp и главный бармалей в студии интернет-решений Сибирикс. В свободное от работы время пилотирую спортивные самолеты. Мой любимый SU29 (зверь!). Автор книг “Настольная книга project-менеджера” и “Тайм-менеджмент для тех, у кого лапки”.

Книжки

Обожаю программировать, но сейчас это скорее хобби, поскольку по работе стоят совершенно другие цели и задачи. Найти меня можно в телеграм-канале @sibirix.

P.s. Сингулярность ближе, чем ты думаешь)

Теги:
Хабы:
+10
Комментарии7
1

Публикации

Истории

Работа

Ближайшие события

25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань