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

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

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

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

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

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

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

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

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

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

Предупреждения:

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

Сервер

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

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

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

5 до 10мс. На i3 второго поколения встроенный логгер показывает время выполнения для разных запросов от 0. Запущенный 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 содержит некоторые константы необходимые во всех пакетах (типы данных, типы действий, типы пользователей).

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

Точки входа API

URL

Data

Description

get /pub/(goods|posts|users|cmnts|files)

-

Получение массива актуальных объявлений, постов, пользователей, комментариев, файлов

get /pub/mtimes

-

Получение времени последнего изменения для каждого типа данных

post /pub/login

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

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

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

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

URL

Не будем имитировать многостраничный документ, вместо этого используем hash страницы с query-параметрами. Используем для навигации. Для переходов можно использовать обычный <a> без 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

Для загрузки обновлённых записей через небольшие промежутки времени делаем запрос к /pub/mtimes, если время последнего изменения для какого-либо типа отличается от локального, загружаем список этого типа. Для обмена данными с сервером будем использовать fetch api. Что у нас получилось: Да, можно было реализовать уведомление об обновлениях через 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))
}

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

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

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

Cache

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

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' })) }})
}

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

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 на каждый ввод, чтобы предотвратить их потерю.
  • Во всех компонентах используем иммутабельный режим, в котором свойство-объект считается изменённым только только при получении новой ссылки, независимо от изменения полей, таким образом немного ускоряем наше приложения, пускай и за счет небольшого увеличения объёма кода.

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

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

Для примера — 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, нужны ли подобному приложению полифиллы и es5, в каких проектах можно использовать подобное приложение (много просмотров, мало изменений, комментарии всего).
Исходный код, примерная инструкция по установке и демо по ссылке (просьба вандалить тестировать в рамках УК РФ).

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

PHP-Дайджест № 152 (11 – 25 марта 2019)

"==>" все же уродство как по мне. описанные в rfc проблемы с неоднозначностью синтаксиса и сложностями реализации в рамках используемого парсера. Идеально, конечно, ($x) => $x + $y, но тут см. Из оставшихся вариантов мне наиболее симпатичен \($x) => $x ...

Сохранится ли YouTube таким, каким мы его знаем?

В то время, когда россияне пытаются бороться против изоляции рунета, жители Европейского союза выходят на митинги с требованиями остановить принятие законов, регламентирующие использование платформы YouTube. При этом, основной лозунг на демонстрациях — «Нет цензуре в интернете». Статья 13 Депутаты Европарламента ...