Интернет или ничего: как заставить PHP-разработчика ERP-системы писать под Windows

от автора

Первая версия нашей системы складского учета на PHP и MySQL вышла в 2011 году и мы изначально ориентировались на работу только в интернет. Тогда еще слово “облако” было не в ходу, потому мы называли себя “веб-ориентированная” и нужно было объяснять клиентам что это и почему. В качестве аргумента мы использовали фразу, которую приписывают  Биллу Гейтсу, сказанную им лет 25 тому назад. 

«Если вашего бизнеса нет в Интернете, то вас нет в бизнесе! Скоро на рынке останется два вида компаний: те, кто в Интернете и те, кто вышел из бизнеса.» 

Тогда мы были твердо уверены, что будет именно так и очень скоро.

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

При этом, мы довольно долго поддерживали и локальную установку под Windows на Denwer. Хотя клиенты с локальной установкой не были нашей целевой аудиторией, просто нам нравился Denwer. Сейчас товарный учет невозможно представить без интернета, потому что здесь и интернет-магазин, и маркетплейсы, и интеграции с мессенджерами. Т.е. запустить систему локально можно, но смысла в этом очень мало, если только для учета домашнего консервирования…

Но тут в дверь постучал Великий Чебурнет… И нужно было срочно что-то придумать для работы оффлайн и синхронизации данных при восстановлении интернета.

Задача-минимум звучала так: сделать, чтобы продавец видел актуальные остатки и справочник клиентов и мог оформить продажу даже без интернета, а когда связь вернётся — продажи сами уехали бы в онлайн-базу. Простой задача оказалась только на словах. По дороге мы успели чуть не утащить в десктоп весь свой серверный PHP, замахнуться на «один код сразу под все платформы» — и в итоге выкинули и то, и другое, оставив только Windows приложение. Рассказываем, как и почему.

Наш основной стек — PHP, осваивать десктоп под Windows или даже заказывать на сторону заняло бы неприемлемое количество часов. Потому, не оставалось ничего другого, как довериться ИИшке…

Именно тут и спрятана половина граблей из этой истории. ИИ вёл уверенно и убедительно — в том числе уверенно вёл не туда: предлагал навернуть сложность там, где надо было остановиться и сменить подход, обещал, что «всё будет легко и быстро», когда мы на ровном месте теряли дни.

Условия задачи и почему это важно

Прежде чем выбирать технологию, мы выписали ограничения. Именно они отсекли «очевидные» решения.

  • Пользователь — не админ и не разработчик. Установка в идеале без прав администратора и без терминала. Никаких «откройте консоль и введите команду».

  • Данные должны жить локально. Справочники товаров и клиентов, остатки — держать на машине и быстро искать, в том числе по кириллице.

  • Запись, а не только чтение. Мало показать данные — нужно оформить продажу оффлайн и гарантированно не потерять её до отправки.

  • Никакого ручного слияния. Конфликты данных продавец разруливать не должен и не сможет.

  • Минимум поддержки. Каждый нестандартный шаг установки — это звонок в поддержку, умноженный на число точек.

А если коротко, то нужно было быстро, просто и надежно. Но все-таки с возможностью расширения функционала, если Чебурнет решит остаться с нами подольше (не хотелось бы…)

Дальше — хроника.

Идея №1 “Соблазнительная”: локальный вебсервер

Логичная идея: не собирать руками, а взять готовый «джентльменский набор» — Apache + PHP + MySQL одним пакетом, особенно с учетом того, что когда-то у нас была готовая версия под Денвер.

Но Денвер давно не развивается, а дистрибутивы его конкурентов имеют огромные размеры. Посмотрели большую их часть, а также локальный сервер PHP (php -S localhost:8000). Выглядело соблазнительно: просто скопируем код на машину клиента, подложим локальную базу — и пусть работает «как на сервере, только локально».

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

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

Идея № 2 “Сомнительная”: Electron как оболочка над локальным PHP

ИИшка почему-то упорно считал, что наша главная задача — спрятать черное консольное окно запуска и склонял к “изящному компромиссу” локальному вебсерверу. Берём Electron — он показывает встроенное окно-WebView, умеет автозапуск, иконку в трее, фоновые задачи. А внутри пусть крутится наш родной PHP через php -S, работая с локальными данными. Синхронизацию вешаем на Node-процесс Electron. Получалось красиво: пользователь открывает .exe и видит приложение, а не «зайдите на localhost:8787», при этом мы переиспользуем серверный код почти как есть.

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

Идея №3 “Параллельная”: а может, вообще PWA?

Рассмотрели также и противоположную идею: ничего не устанавливать. Прогрессивное веб-приложение (PWA): Service Worker кэширует интерфейс, приложение «ставится» из браузера, работает оффлайн.

Для оффлайн-чтения статики — отлично. Но под наши условия посыпалось: полноценная локальная база с быстрым поиском по большим справочникам и надёжной записью продаж — это не задача для кэша Service Worker; ограничения хранилища в браузере (особенно на старых и корпоративных конфигурациях) делали поведение непредсказуемым — браузер может «прибрать» данные, когда захочет, а для кассы потеря записанной продажи равна потерянным деньгам. PWA отложили.

Идея №4 “Большая мечта“: один код сразу под все платформы (Capacitor)

А теперь — про самую красивую и самую коварную идею на этом пути. Раз мы всё равно заворачиваем веб-приложение в нативную оболочку, почему бы не замахнуться сразу на всё? Есть Capacitor — он позиционируется как «напиши один раз, собери под десктоп, Android и iOS». Звучало как мечта: один и тот же наш веб-фронт превращается и в Windows-программу, и в приложение для продавца с планшетом, и теоретически в iOS — а Capacitor якобы всё это берёт на себя сам. Мы загорелись и заложили проект с прицелом именно на кроссплатформу.

Реальность оказалась заметно прозаичнее лозунга. «Capacitor сделает всё сам» работает ровно до того момента, пока твоё приложение не начинает по-настоящему трогать систему. А оффлайн-касса трогает её постоянно: локальная база, файлы, фоновая синхронизация, хранение секретов, привязка к устройству. И вот тут выяснялось одно и то же: вот это под десктоп решается так, а под Android — совсем иначе; вот это хранилище здесь одно, а там другое; фоновая работа на Windows — это простой таймер в живущем процессе, а на Android — отдельная дисциплина с системными ограничениями, которую так просто не переиспользуешь. «Один код» рассыпался на «общий веб-фронт плюс отдельная платформенная склейка под каждую цель».

Мы честно оценили объём этой склейки — и поняли, что кроссплатформенная сборка из одной кодовой базы экономит куда меньше, чем обещает, а сложностей добавляет сразу. Поэтому от амбиции «всё и сразу» отказались: решили сделать одну платформу, которая нужна клиентам прямо сейчас, — Windows. А мобильную версию, если она понадобится, делать отдельным проектом — с трезвым пониманием, что общим будет веб-фронт, а платформенную часть всё равно придется писать заново.

Главный урок этой ветки: фраза «фреймворк сделает кроссплатформу за вас» верна только для той части приложения, которая не касается операционной системы. Чем больше нативной специфики — тем меньше магии.

Что в итоге: Electron + клиент на JS и SQLite, только Windows

Когда отвалились и «PHP внутри», и «всё сразу», развязка стала очевидной. Мы выкинули PHP из клиента целиком и переписали локальную часть на JavaScript, а данные положили в SQLite (через better-sqlite3). Electron остался — но уже честной оболочкой настольного приложения под Windows, а не «матрёшкой и не частью кроссплатформенной мечты. Что получили:

  • Никакого PHP-рантайма в дистрибутиве. Один стек на клиенте, ничего лишнего тащить не надо.

  • Локальная база SQLite — справочники, остатки и продажи лежат в одном файле и доступны полностью оффлайн, поиск быстрый.

  • Нормальный установщик под Windows (NSIS), доступ к файловой системе, трей, автозапуск.

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

Как устроена синхронизация

Здесь живёт главное инженерное решение. Чтобы не разбираться с конфликтами слияния (а продавец их разбирать точно не должен), мы сделали потоки данных строго однонаправленными.

Справочники: только сервер → клиент. Сервер на PHP по запросу формирует выгрузку: справочники товаров и клиентов и настройки. Технически это файл базы server.db (SQLite) плюс config.json с настройками, упакованные в server.zip. Клиент по расписанию забирает архив, распаковывает и вливает данные в свою локальную базу через INSERT OR REPLACE. Продавец справочники не редактирует — значит, со стороны клиента правок нет, значит, и конфликтов нет: сервер всегда прав.

Сервер при этом отдает только изменившиеся данные справочника товаров. Раз с интернетом у нас проблемы, важно было сделать объем данных минимальным.

Продажи: только клиент → сервер. Оформленная оффлайн продажа складывается в локальную очередь. Как только появляется сеть, клиент отправляет накопленное в онлайн-базу по REST API. Здесь источник истины — клиент: продажа создана на месте, сервер её принимает. При этом с продажей можно передавать и данные нового контрагента, API на сервере создаст его в справочнике.

За счёт того, что по каждой сущности направление ровно одно, исчезает сам класс задач «а чья версия новее». Справочники текут вниз, продажи — вверх, посередине они не пересекаются.

Вкладка Данные

Вкладка Данные
Вклада Продажа

Вклада Продажа

Неожиданные грабли

SQLite не очень то и лайт?

На сервере формирование SQLite оказалось неприемлемо медленным (большой справочник товаров целиком при первой загрузке не уложился бы в 30 секунд лимита PHP на виртуальном хостинге). Оказалось, что SQLite после каждого INSERT честно делает fsync на диск. Лечится это одной строчкой осознания: обернуть всю заливку в одну транзакцию (BEGIN TRANSACTION … COMMIT). Тогда записи буферизуются и сбрасываются на диск разом, а не по одной, — ускорение на порядок. 

Portable-сборка, не знавшая, где она

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

Подвох в том, что portable-сборка Electron при каждом запуске распаковывается во временную папку (%TEMP%) и стартует уже оттуда. А значит, стандартный способ приложения узнать собственный путь — app.getPath('exe') — возвращал путь в Temp, а не туда, где реально лежит .exe. Ломалось сразу две вещи: программа не могла найти свою рабочую папку с настройками и данными (они лежат рядом с экзешником), и в автозагрузку Windows прописывался временный путь, которого к следующему запуску уже не существовало.

Дальше начался марафон обходных решений, где каждое тянуло за собой следующее. Переменная PORTABLE_EXECUTABLE_PATH от сборщика то проставлялась, то нет — в зависимости от конфигурации. Попытка вычислить реальный путь через PowerShell (Get-Process … | Select Path) у клиента вообще повесила приложение намертво: окно не появлялось, процесс висел. Добавили повторные попытки с паузами — стало срабатывать «через раз»: гонка, PowerShell не успевал найти процесс. Пробовали маркер-файл с сохранённым путём, передачу пути аргументом командной строки, запись в реестр. Код ради того, чтобы приложение нашло собственную папку, разрастался до неприличия.

Отрезвление пришло с простой мысли: нормальные Windows-приложения не занимаются археологией в поисках себя — они просто знают, где лежат, потому что их туда поставил установщик. Вся борьба была симптомом неправильно выбранного формата доставки. Как только мы отказались от portable и собрали обычный установщик на NSIS (в режиме «для текущего пользователя», без прав администратора), проблема исчезла целиком: путь известен, автозагрузка стабильна, рабочая папка на месте. 

Регистронезависимый поиск/LIKE не работает для кириллицы 

«Иванов» и «иванов» он считает разными словами и поиск по справочнику находил клиента в одном регистре и терял в другом. К счастью, решилось достаточно просто, зарегистрировали в better-sqlite3 свою SQL-функцию на JavaScript, которая приводит текст к нижнему регистру.

Токен, который нельзя унести домой

Отдельная история вышла с токеном доступа к API. Его вносит администратор, а продавец-кассир видеть его в виде текста в файле настроек не должен. Первым желанием было токен зашифровать, и тут Electron предлагает удобное встроенное средство — safeStorage, которое шифрует данные системными механизмами Windows.

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

В итоге мы развернули это ограничение в свою пользу, только сделали привязку сами, без safeStorage. Токен вносится один раз прямо на точке и шифруется ключом, который выводится из «железа» машины, перенести установку с сохраненным токеном на другой компьютер нельзя. То, что начиналось как досадное препятствие для рассылки настроек, обернулось ровно тем механизмом, который не даёт унести рабочий доступ к базе за пределы кассы. Урок из тех, что доходят только на практике: безопасность нельзя проектировать в отрыве от того, как продукт реально устанавливают и обслуживают. 

Кому интересно попробовать

Программу мы сделали как модуль офлайн-режима к своей системе учёта. Если интересно покрутить руками — выложили бесплатный демо-доступ: можно скачать программу и подключиться к демо-базе по тестовому ключу, ничего не покупая. Ссылка и стек — в карточке модуля: https://www.webnice.biz/catalog/product/scif-offline/

Будем рады вопросам в комментариях — особенно интересно будет узнать ваш опыт синхронизации локальной MySQL с ее мастер-копией в облаке в обе стороны (у нас первичные ключи числовые AUTO_INCREMENT). Кто решал это в бою — на чём остановились: интерливинг автоинкремента, переход на UUID, составные ключи, SymmetricDS, событийная модель? И где собрали больше всего граблей — на самих ID или уже на конфликтах и остатках?

ссылка на оригинал статьи https://habr.com/ru/articles/1054728/