[Из песочницы] Аскетичный вебъ: прототип барахолки на go и js

habr.png

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

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

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


  1. Серверная часть:

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


  2. Клиентская часть:

    а) Предоставляет необходимый функционал для создания и потребления контента
    б) Интерфейс удобен для регулярного использования, минимальный путь до любого действия, максимальное количество данных на экран
    в) Вне связи с сервером доступны все возможные в этой ситуации функции
    г) Интерфейс отображает актуальную версию состояния и контента, без перезагрузок и ожидания
    д) Перезапуск приложения не сказывается на его состоянии
    е) По возможности переиспользуем DOM-элементы и JS-код
    ж) Не станем использовать сторонние библиотеки и фреймворки в рантайме
    з) Вёрстка семантична для доступности, парсеров и т. д.
    и) Навигация по основному контенту доступна с помощью URL и клавиатуры


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


Предупреждения:
  • Хочу принести извинения неизвестным мне авторам изображений, использованных в демо без разрешений, а так же Гёссе Г., Прозоровской Б.Д. и издательству «Библиотека Флорентия Павленкова» за использование отрывков из произведения «Сиддхартха».
  • Автор не настоящий программист, не советую использовать код или приёмы использованные в данном проекте, если вы не знаете что делаете.
  • Прошу прощения за стиль кода, можно было написать более читабельно и очевидно, но это не весело. Проект для души и для друга, as is как говорится.
  • Также прошу прощения за уровень грамотности, в английском тексте в особенности. Лет спик фром май харт.
  • Работоспособность представленного прототипа тестировалось в [chromium 70; linux x86_64; 1366×768], буду предельно признателен пользователям других платформ и устройств за сообщения об ошибках.
  • Это прототип и предлагаемая тема для обсуждения — подходы и принципы, прошу всю критику реализации и эстетической стороны сопровождать аргументами.


Сервер

Языком для сервера будет golang. Простой, быстрый язык с отличной стандартной библиотекой и документацией… немного раздражающий. Первоначальный выбор пал на elixir/erlang, но т. к. go я уже знал (относительно), решено было не усложнять (да и необходимые пакеты были только для go).

Использование веб-фреймворков в go-сообществе не поощряется (обоснованно, стоит признать), мы выбираем компромисс и используем микрофреймворк labstack/echo, тем самым сокращая количество рутины и, как мне кажется, не много проигрывая в производительности.

В качестве базы данных используем tidwall/buntdb. Во-первых встроенное решение удобнее и уменьшает накладные расходы, во-вторых in-memory + key/value — модно, стильно быстро и нет нужды в кэше. Храним и отдаём данные в JSON, валидируя только при изменении.

На i3 второго поколения встроенный логгер показывает время выполнения для разных запросов от 0.5 до 10 мс. Запущенный wrk на той же машине также показывает достаточные для наших целей результаты:

➜  comico git:(master) wrk -t2 -c500 -d60s http://localhost:9001/pub/mtimes 
Running 1m test @ http://localhost:9001/pub/mtimes
  2 threads and 500 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    20.74ms   16.68ms 236.16ms   72.69%
    Req/Sec    13.19k   627.43    15.62k    73.58%
  1575522 requests in 1.00m, 449.26MB read
Requests/sec:  26231.85
Transfer/sec:      7.48MB
➜  comico git:(master) wrk -t2 -c500 -d60s http://localhost:9001/pub/goods 
Running 1m test @ http://localhost:9001/pub/goods
  2 threads and 500 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    61.79ms   65.96ms 643.73ms   86.48%
    Req/Sec     5.26k   705.24     7.88k    70.31%
  628215 requests in 1.00m, 8.44GB read
Requests/sec:  10454.44
Transfer/sec:    143.89MB


Структура проекта

Пакет comico/model разделён на три файла:
model.go — содержит описание типов данных и общие функции: создание/обновление (buntdb не различает эти операции и наличие записи мы проверяем вручную), валидация, удаление, получение одной записи и получение списка;
rules.go — содержит правила валидации конкретного типа и функции логирования;
files.go — работа с изображениями.
Тип Mtimes хранит данные о последнем изменении остальных типов в бд, таким образом сообщая клиенту какие данные изменились.

Пакет comico/bd содержит обобщенные функции взаимодействия с бд: создание, удаление, выборка и т. д. Buntdb сохраняет все изменения в файл (в нашем случае раз в секунду), в текстовом формате, что в некоторых ситуациях удобно. Файл бд не редактируется, изменения в случае успеха транзакции дописываются в конец. Все мои попытки нарушить целостность данных не увенчались успехом, в худшем случае теряются изменения за последнюю секунду.
В нашей реализации каждый тип соответствует отдельной БД в отдельном файле (кроме логов, которые хранятся исключительно в памяти и при перезагрузке обнуляются). Это обусловлено в большей степени удобством резервного копирования и администрирования, небольшой плюс — транзакция открытая на редактирование блокирует доступ только к одному типу данных.
Данный пакет может быть без особо труда заменён на аналогичный использующий другую базу данных, SQL к примеру. Для этого достаточно реализовать следующие функции:

func Delete(db byte, key string) error
func Exist(db byte, key string) bool
func Insert(db byte, key, val string) error
func ReadAll(db byte, pattern string) (str string, err error)
func ReadOne(db byte, key string) (str string, err error)
func Renew(db byte, key string) (err error, newId string)

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

Пакет comico/server содержит информацию о роутах. Также, буквально в пару строк (спасибо разработчикам Echo), настроена авторизация с помощью JWT, заголовки CORS, CSP, логгер, раздача статики, gzip, автосертификат ACME и т. д.


Точки входа API


URL Data Description
get /pub/(goods|posts|users|cmnts|files) - Получение массива актуальных объявлений, постов, пользователей, комментариев, файлов
get /pub/mtimes - Получение времени последнего изменения для каждого типа данных
post /pub/login { id*: логин, pass*: пароль } Возвращает JWT-токен и время его действия
post /pub/pass { id*, pass* } Создаёт нового пользователя, если данные корректны
put /api/pass { id*, pass* } Обновление пароля
post|put /api/goods { id*, auth*, title*, type*, price*, text*, images: [], Table: {key: value} } Создание/обновление объявления
post|put /api/posts { id*, auth*, title*, type*, text* } Создание/обновление поста форума
post|put /api/users { id*, title, type, status, scribes: [], ignores: [], Table: {key: value} } Создание/обновление пользователя
post /api/cmnts { id*, auth*, owner*, type*, to, text* } Создание комментария
delete /api/(goods|posts|users|cmnts)/[id] - Удаляет запись с идентификатором id
get /api/activity - Обновляет время последнего прочтения входящих комментариев для текущего пользователя
get /api/(subscribe|ignore)/[tag] - Добавляет или удаляет (при наличии) пользователю tag в список подписок/игнора
post /api/upload/(goods|users) multipart (name, file) Загружает фото объявления / аватар пользователя

* — обязательные поля
api — требует авторизации, pub — нет

При get-запросе, не совпадающим с вышеперечисленным, сервер ищет файл в директории для статики (к примеру /img/* — изображения, /index.html — клиент). Любая точка апи при успехе возвращает код ответа 200, при ошибке — 400 или 404 и краткое сообщение при необходимости.

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


Клиент

Мне нравится концепция реактивного веб'а, считаю что большинство современных сайтов / приложений стоит делать либо в рамках этой концепции, либо полностью статичными. С другой стороны несложный сайт с мегабайтами JS-кода не может не удручать. На мой взгляд эту (и не только) проблему сможет решить Svelte. Этот фреймворк (или скорее язык построения реактивных интерфейсов) не уступает в необходимом функционале тому же Vue, но обладает неоспоримым преимуществом — компоненты компилируются в ванильный JS, что сокращает как в размер бандла, так нагрузку на виртуальную машину (bundle.min.js.gz нашей барахолки занимает скромные, по нынешним меркам, 24КБ). Подробности вы можете узнать из официальной документации.

Выбираем для клиентской части нашей барахолки SvelteJS, желаем всяческих благ Rich Harris и дальнейшего развития проекту!

PS Никого не хочу обидеть. Уверен, что для каждого специалиста и каждого проекта подходит свой инструментарий.


Клиент / данные


URL

Используем для навигации. Не будем имитировать многостраничный документ, вместо этого используем hash страницы с query-параметрами. Для переходов можно использовать обычный без js.

Разделы соответствуют типам данных: /#goods, /#posts, /#users.
Параметры: ? id=идентификатор_записи, ? page=номер_страницы, ? search=поисковый_запрос.

Несколько примеров:


  • /#posts? id=1542309643&page=999&search={auth: anon} — раздел posts, id поста — 1542309643, страница комментариев — 999, поисковый запрос — {auth: anon}
  • /#goods? page=2&search=сиддхартха — раздел goods, страница раздела — 2, поисковый запрос — сиддхартха
  • /#goods? search=wer{key: value}t — раздел goods, поисковый запрос — состоит из поиска подстроки wert в заголовке или тексте объявления и подстроки value в свойстве key табличной части объявления
  • /#goods? search={model:100, display:256} — думаю тут всё понятно по аналогии

Функции парсинга и формирования урл в нашей реализации выглядят так:

window.addEventListener('hashchange', function() {
  const hash = location.hash.slice(1).split('?'), result = {}
  if (!!hash[1]) hash[1].split('&').forEach(str => {
    str = str.split('=')
    if (!!str[0] && !!str[1]) 
      result[decodeURI(str[0]).toLowerCase()] = decodeURI(str[1]).toLowerCase()
  })
  result.type = hash[0] || 'goods'
  store.set({ hash: result })
})

function goto({ type, id, page, search }) {
  const { hash } = store.get(), args = arguments[0], query = []
  new Array('id', 'page', 'search').forEach(key => {
    const value = args[key] !== undefined ? args[key] : hash[key] || null
    if (value !== null) query.push(key + '=' + value)
  })
  location.hash = (type || hash.type || 'goods') + 
    (!!query.length ? '?' + query.join('&') : '')
}


API

Для обмена данными с сервером будем использовать fetch api. Для загрузки обновлённых записей через небольшие промежутки времени делаем запрос к /pub/mtimes, если время последнего изменения для какого-либо типа отличается от локального, загружаем список этого типа. Да, можно было реализовать уведомление об обновлениях через SSE или WebSocket’ы и инкрементную подгрузку, но в данном случае обойдёмся без этого. Что у нас получилось:

async function GET(type) {
  const response = await fetch(location.origin + '/pub/' + type)
    .catch(() => ({ ok: false }))
  if (type === 'mtimes') store.set({ online: response.ok })
  return response.ok ? await response.json() : []
}

async function checkUpdate(type, mtimes, updates = {}) {
  const local = store.get()._mtimes, net = mtimes || await GET('mtimes')
  if (!net[type] || local[type] === net[type]) return
  const value = updates['_' + type] = await GET(type)
  local[type] = net[type]; updates._mtimes = local
  if (!!value && !!value.sort) store.set(updates)
}

async function checkUpdates() {
  setTimeout(() => checkUpdates(), 30000)
  const mtimes = await store.GET('mtimes')
  new Array('users', 'goods', 'posts', 'cmnts', 'files')
    .forEach(type => checkUpdate(type, mtimes))
}

Для фильтрации и пагинации используем вычисляемые свойства Svelte, основываясь на данных навигации. Направление вычисляемых значений таково: items (массивы записей приходящие от сервера) => ignoredItems (отфильтрованные записи на основе списка игнора текущего пользователя) => scribedItems (отфильтровывает записи по списку подписок, если такой режим активирован) => curItem и curItems (вычисляет текущие записи в зависимости от раздела) => filteredItems (фильтрует записи в зависимости от поискового запроса, если запись одна — фильтрует комментарии к ней) => maxPage (вычисляет количество страниц из расчета 12 записей/комментариев на страницу) => pagedItem (возвращает конечный массив записей/комментариев на основе номера текущей страницы).

Отдельно вычисляются комментарии и изображения (comments и _images), группируясь по типу и записи-владельцу.

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


Cache

Согласно решению делать offline-приложение, реализуем хранение записей и некоторых аспектов состояния в localStorage, файлов изображений в CacheStorage. Работа с localStorage предельно проста, договоримся что свойства обладающие префиксом »_» при изменении автоматически сохраняются и восстанавливаются при перезагрузке. Тогда наше решение может выглядеть так:

store.on('state', ({ changed, current }) => {
  Object.keys(changed).forEach(prop => {
    if (!prop.indexOf('_')) 
      localStorage.setItem(prop, JSON.stringify(current[prop]))
  })
})

function loadState(state = {}) {
  for (let i = 0; i < localStorage.length; i++) {
    const prop = localStorage.key(i)
    const value = JSON.parse(localStorage.getItem(prop) || 'null')
    if (!!value && !prop.indexOf('_')) state[prop] = value
  }
  store.set(state)
}

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

async function cacheImages(newFiles) {
  const oldFiles = JSON.parse(localStorage.getItem('_files') || '[]')
  const cache = await caches.open('comico')
  oldFiles.forEach(file => { if (!~newFiles.indexOf(file)) {
    const [ id, type ] = file.split(':')
    cache.delete(`/img/${type}_${id}_sm.jpg`)
  }})
  newFiles.forEach(file => { if (!~oldFiles.indexOf(file)) {
    const [ id, type ] = file.split(':'), src = `/img/${type}_${id}_sm.jpg`
    cache.add(new Request(src, { cache: 'no-cache' }))
  }})
}

Затем нужно переопределить поведение fetch так, чтобы файл брался из CacheStorage без коннекта к серверу. Для этого придётся воспользоваться ServiceWorker’ом. Заодно настроим сохранение в кэш других файлов для работы вне связи с сервером:

const CACHE = 'comico', FILES = [ '/', '/bundle.css', '/bundle.js' ]

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES))
    .then(() => self.skipWaiting()))
})

self.addEventListener('fetch', (e) => {
  const r = e.request
  if (r.method !== 'GET' || !!~r.url.indexOf('/pub/') || !!~r.url.indexOf('/api/')) return
  if (!!~r.url.lastIndexOf('_sm.jpg') && e.request.cache !== 'no-cache') 
    return e.respondWith(fromCache(r))
  e.respondWith(toCache(r))
})

async function fromCache(request) {
  return await (await caches.open(CACHE)).match(request) || 
    new Response(null, { status: 404 })
}

async function toCache(request) {
  const response = await fetch(request).catch(() => fromCache(request))
  if (!!response && response.ok) 
    (await caches.open(CACHE)).put(request, response.clone())
  return response
}

Выглядит немного коряво, но свои функции выполняет.


Клиент / интерфейс

Структура компонентов:
index.html | main.js
== header.html — содержит логотип, строку состояния, главное меню, нижнее навигационное меню, форму отправки комментария
== aside.html — является контейнером для всех модальных компонентов
==== goodForm.html — форма добавления и редактирования объявления
==== userForm.html — форма редактирования текущего пользователя
====== tableForm.html — фрагмент формы для ввода табличных данных
==== postForm.html — форма для поста форума
==== login.html — форма логина/регистрации
==== activity.html — отображает комментарии обращенные текущему пользователю
==== goodImage.html — просмотр основного и дополнительных фото объявления
== main.html — контейнер для основного содержимого
==== goods.html — карточки списка или одиночного объявления
==== users.html — то же для пользователей
==== posts.html — думаю, понятно
==== cmnts.html — список комментариев к текущей записи
====== cmntsPager.html — пагинация для комментариев


  • В каждом компоненте мы стараемся минимизировать количество html-тэгов.
  • Классы используем только в качестве показателя состояния.
  • Схожие функции выносим в стор (свойства и методы svelte store можно использовать напрямую из компонентов добавляя к ним префикс '$').
  • Большинство функций ожидают пользовательского события или изменения определённых свойств, манипулируют данными стейта, сохраняют обратно в стейт результат свой работы и завершаются. Таким образом достигается малая связанность и расширяемость кода.
  • Для видимой скорости переходов и других UI-событий мы по возможности отделяем манипуляции с данными, происходящими в фоне и действия связанные с интерфейсом, который в свою очередь использует текущий результат вычислений, перестраиваясь при необходимости, остальную работу любезно выполнит фреймворк.
  • Данные заполняемой формы сохраняем в localStorage на каждый ввод, чтобы предотвратить их потерю.
  • Во всех компонентах используем иммутабельный режим, в котором свойство-объект считается изменённым только только при получении новой ссылки, независимо от изменения полей, таким образом немного ускоряем наше приложения, пускай и за счет небольшого увеличения объёма кода.


Клиент / управление

Для управления с помощью клавиатуры задействуем следующие комбинации:
Alt+s / Alt+a — переключает страницу записей вперёд / назад, для одной записи переключает страницу коментариев.
Alt+w / Alt+q — осуществляет переход ко следующей / предыдущей записи (если таковые существуют), работает в режиме списка, одной записи и просмотра изображения
Alt+x / Alt+z — прокручивает страницу вниз / вверх. В режиме просмотра изображений переключает изображения вперёд циклично / назад
Escape — закрывает модальное окно, если открыто, возвращает к списку, если открыта одиночная запись, отменяет поисковый запрос в режиме списка
Alt+c — фокусирует на поле поиска или ввода комментария, в зависимости от текущего режима
Alt+v — включает / отключает режим просмотра фото для одиночного объявления
Alt+r — открывает / закрывает список входящих комментариев для авторизованного пользователя
Alt+t — переключает светлую / тёмную темы оформления
Alt+g — список объявлений
Alt+u — пользователей
Alt+p — форум
Знаю, во многих браузерах эти сочетания используются самим браузером, однако для моего хрома я не смог придумать что-то удобнее. Буду рад вашим предложениям.

Помимо клавиатуры конечно же можно использовать консоль браузера. Для примера — store.goBack (), store.nextPage (), store.prevPage (), store.nextItem (), store.prevItem (), store.search (stringValue), store.checkUpdate ('goods'||'users'||'posts'||'files'||'cmnts') — делают то что подразумевает из название; store.get ().comments и store.get ()._images — возвращает группированные файлы и комментарии; store.get ().ignoredItems и store.get ().scribedItems — списки игнорируемых и отслеживаемых вами записей. Полный список всех промежуточных и вычисленных данным доступен из store.get (). Не думаю что это всерьёз может кому-то понадобиться, но, к примеру, отфильтровать записи по пользователю и удалить мне показалось вполне удобно именно из консоли.


Заключение

На этом знакомство с проектом можно закончить, больше подробностей вы можете найти в исходных текстах. Как итог у нас получилось довольно быстрое и компактное приложение, в большинстве валидаторов, чекеров безопасности, скорости, доступности и т. п. показывает высокие результаты без целенаправленной оптимизации.
Хочется узнать мнение сообщества насколько оправданно использованные в прототипе подходы к организации приложений, какие могут быть подводные камни, что бы вы реализовали принципиально по-другому?
В частности вопросы: хранение всех данных (кроме паролей) на клиенте, агрессивное кэширование изображений, состояние индексации SPA поисковиками на сегодняшний день (почему PageSpeed правильно парсит приложение, а гугл-робот нет?), актуальность использования hash страницы для навигации, возможные недостатки in-memory noSQL в подобного типа проектах (и buntDB в частности), проектирование интерфейса для гиков (без лишних подсказок, упрощений и т. д.), необходимость в noscript-версии приложения в 2019…
Исходный код, примерная инструкция по установке и демо по ссылке (просьба вандалить тестировать в рамках УК РФ).

Постскриптум. Немного меркантильного в завершение. Подскажите с таким уровнем реально начать программировать за деньги? Если нет, на что обратить внимание в первую очередь, если да, подскажите где сейчас ищут интересную работу на схожем стеке. Спасибо.

Постпостскриптум. Ещё немного о деньгах и работе. Как вам такая идея: предположим человек готов работать над интересным ему проектом за любую з/п, однако данные о задачах и их оплате будут доступны публично (желательна доступность и кода для оценки качества исполнения), в случае если оплата будет существенно ниже рынка конкуренты работодателя могут предложить большие деньги за выполнение их задач, если выше — многие исполнители смогут предложить свои услуги по меньшей цене. Не будет ли такая схема в некоторых ситуациях более оптимально и справедливо балансировать рынок (IT)?

© Habrahabr.ru