Spark-in.me - как, зачем и почему. Часть 1 - почему? Spark-in.me - как, зачем и почему. Часть 2 - как? Архитектура приложения и структура БД Spark-in.me Часть 3 - DIY поддержка и админство сайта Spark-in.me Часть 4 - Базовое админство для обычных человеков (postgres и не только) Spark-in.me Часть 5 - переход на HTTPSСтатьи цикла
Статьи in a nutshell
Введение
Описав зачем и почему можно попробовать начать описывать как мы делали конкретные вещи. В этой статье будет описана общая архитектура и структура данных. Понятное дело, что в идеальном мире выбор инструментов должен трактоваться бизнес / какой-то потребностью и умные люди должны подолгу сидеть над табличкой архитектур, сделать рациональный выбор и потом команда должна пилить это долго и упорно.
На практике часто получается немного иначе:
- Мы умеем то-то и у нас есть небольшая кодовая база;
- Мы понимаем, что есть такие-то потребности в виде списка, но формализация всего по времени сопоставима с непосредственной имплементацией;
- Поэтому лучше планирование и исполнение заложить в несколько коротких итераций;
- Готовые решения не нравятся по причине отсутствия каких-либо фич или отсутствия владения контентом;
Сразу скажу, что с точки зрения дизайна самого приложения / архитектуры я частично вдохновлялся такими примерами:
- Админка медузы и сама медуза как самое прогрессивное с технической точки зрения СМИ (про контент молчу, он везде одинаковый);
- Тем, как работала публикация на моем прошлом месте работы;
- Принципом максимального отсутствия бизнес-логики в business tier и на клиенте - по сути большая часть новых вещей просто берется из базы и прокидывается в АПИ и на морду (зачем плодить уровни, если я один и контент простой?);
- Не возьмусь вспоминать где это сделано лучше всего, но все SEO фичи должны быть с самого начала (RSS, Sitemap, schema.org/ld+json, og-теги, мета-теги);
Из этого небольшого списка следуют такие принципы:
- Должно поддерживаться много авторов;
- Создание статей делается в простом WYSIWYG редакторе, картинки заливаются отдельно на страничке;
- Контент должен быть максимально отделен от формы, и желательно представим во всех возможных форматах без проблем (на практике, остался формат статей HTML, который всеяден, канал в телеграме, его веб-трансляция, и email-подборки статей с канала и сайта. От идеи мультипостинга я отказался, т.к. там слишком высокие пороги на вход и поддержку тучи социальных аккаунтов, хотя технически можно это сделать это хоть сейчас - собственно идея мультипостинга и породила мысль о том, чтобы использовать имеющуюся кодовую базу в таком формате);
- Контент должен быть представлен в такой форме, чтобы любой в интернете мог им поделиться, и при этом автоматически бы подтягивались open-graph теги;
- Нужно хранить минимально нужное количество SEO сущностей в базе, чтобы не пришлось потом их разделять;
- Принцип расширяемости и отделимости каждого компонента приложения от другого. Не совсем соблюдается, т.к. очень много завязано на PostgreSQL;
- Каждая единица контента должна иметь уникальный URL (главная, авторы, теги, статьи, фиксированные страницы). При этом никто не мешает делать автоматизированные подборки (сейчас есть теги и группы тегов + страницы авторов, по идее надо делать также страницы групп тегов);
- Добавление любой, даже самой лютой фичи, должно сводиться к простейшему набору из действий:
- Набор таблиц;
- Набор методов АПИ;
- Набор предобработок по крону или операций с данными (пока не нужно);
- Прокидывание в админку или на морду;
Несложно увидеть, что современные решения типа Medium, Tumblr или менее современные типа Wordpress не совсем этим принципам удовлетворяют. Самая главная проблема таких систем - по сути вы не владеете своим контентом. Я сталкивался с несколькими open-source минималистскими приложениями для блогов, которые в принципе можно использовать, но если они написаны на языке, который вам незнаком, то добавление, допустим, блока "похожие статьи" может стать путешествием в ад.
Имея эти требования в голове, выбор на такие технические решения:
- База PostgreSQL - возможно самая продвинутая open-source база в мире, которая сочетает в себе как реляционную базу так и много нереляционных фич;
- PHP для бекенда админки и для АПИ как самый простой, документированный и быстрый в применении и освоении инструмент;
- ReactJS для морды (хотя можно было бы на PHP все сделать). Изначально предполагалось сделать второй фановый проект, где нужны были приложения через React Native, но он отвалился;
Давайте теперь пройдемся по каждому компоненту отдельно. Я понимаю, что для части вещей сделанное может показаться overkill'ом, но часть этой кодовой базы у меня просто была, часть я дописывал с целью сделать тот же мультипостинг и иметь "правильное" АПИ и прошивку.
Статьи
- Изначально планировалось, что будет мульти-постинг, поэтому тут есть ключ для публикации и цели публикации. На практике используется только публикация на сайт (совершенству нет предела, но никто из нас, как мне кажется, не будет поддерживать паблики ВК и ФБ);
- Статья - основная сущность, она публикуется в теги на определенном языке;
- У тегов есть типы тегов;
- Также глобальные переменные хранятся в виде json в таблице;
- Автор имеет пользователя, от имени которого он входит в админку;
- Обратите внимание, что основные SEO-сущности хранятся в таблицах;
Пользователи, права и сессии пользователей
- У пользователя есть роли, которые объединяются в права. По сути получается, что любой пользователь может иметь любые права через роли;
- Проверка доступности методов делается по статической сессии;
- Для пользователей сайта статическая сессия создается заранее, пользователи работают по ключу (обратите внимание, я знаю про разные криптографические методы, динамические сессии, токены, protobuf и прочее - но я не использую это для простоты приложения - это же информационный контент, зачем его прятать?);
- При входе в админку у автора создается сессия и прописывается в cookies. При повторном заходе не нужно вводить пароль, если в браузере есть кука;
Методы АПИ
Самая неоднозначная вещь, поскольку по большей части состоит из legacy кода, написанного для другой цели. Архитектура вкратце написана для того, чтобы:
- Поддерживалась версионность методов АПИ с логами прошлых версий, чтобы не возиться с поддержкой;
- Добавление метода АПИ сводилось к:
- Внесению записей в таблицы и созданию прав;
- Прописыванию текста фиксированного запроса в таблицу или создании специальной кастомной функции;
- По сути структура выше описывает хранимые php-функции АПИ и тексты простых функций АПИ в виде запросов, хранимых в базе;
- Да, в базе хранятся права доступа к этой же базе. Но запросы с такими данными ходят только в локальной сети, поэтому это не должно быть проблемой;
- Значительная часть логики тут разнесена так:
- Большой класс apiHandler.php, который принимает внешние запросы, проверяет их на валидность, форматирование, верные параметры и выбрасывает ошибки, как в логи так и обратно;
- Большой класс apiExecutor.php, который в случае наличия валидного запроса этот запрос исполняет. Нетрудно догадаться, что изначально между этими двумя сущностями планировался балансировщик нагрузки, который просто пока не нужен;
- Работа с ошибками вынесена в отдельные классы и сущности, которые будут описаны ниже;
- Тут используются функции PostgreSQL по работе с json - многие конфиги и параметры хранятся в виде json, чтобы обеспечить развиваемость и обратную совместимость (и отсутствие геморроя для меня =) );
- В общем случае работу АПИ можно представить так:
- Приходит запрос. Проходит простейший фаервол (все ненужные порты закрыты);
- В виде большой древовидной структуры запрос парсится и проверяется на:
- Валидность типа запроса и его содержимого;
- Валидность типов параметров и их содержимого;
- Наличие обязательных параметров;
- Наличие прав у запрашивающего;
- Если все проверки пройдены, в локальной сети отправляется запрос в apiExecutor.php (разделение сделано было в legacy коде с определенной целью), который делает ряд проверок, смотрит где найти запрос для функции или функцию на php, которую нужно исполнить;
- Естественно везде стоят проверки на SQL-инъекции, проверяется искейпинг и прочее;
- Функции, которые хранятся в виде запросов, естественно имеют экранированные параметры;
Пример параметров одного из запросов АПИ - я не большой фанат технических извращений, по мне простой json внутри POST запросов нормально работает.
{
"key": "very_secret_key",
"method": {
"name": "getTagByAlias",
"version": 1
},
"params": {
"targetId": 2,
"tagAlias": "not-buying-bs",
"getFullArticles": 1
}
}
Статистика по вызовам запросов АПИ
text | count |
---|---|
getSimilarArticlesByArticleAlias | 25421 |
getBlogObjects | 17075 |
getAuthorByAlias | 12977 |
getArticleByAlias | 11412 |
getArticleFeed | 7124 |
getTagInfo | 3989 |
getTagByAlias | 1628 |
getArticleById | 717 |
getBlogSearch | 336 |
getMyArticles | 290 |
getRussianDsCourses | 278 |
getArticlesByAuthor | 271 |
Понятное дело, что в такой архитектуре рука так и просится спросить, а почему методы АПИ не являются хранимыми процедурами? Ответ прост - потому, что мне быстрее написать функцию на php или в примитивном случае запрос, чем хранимую процедуру. По идее наличие хранимых процедур увеличило бы независимость от СУБД, но это не же не коммерческое решение с командой разработчиков =) .
Интересным образом я решил вопрос генерации SEO - обвеса страниц. По сути поставил себе пари - смогу ли я все запихать внутрь 1 SQL запроса, хоть и динамического? В итоге смог. При разбитии на языки навереное будет геморрой, но он может решиться простым разнесением на пару функций (да, я не программист, но я как-то не верю в идеальное ООП, мне больше скорее нравится идея микро-сервисов и "сгустков сути", а не красивого кода).
Вот пример самого извращенного SQL-запроса (вместе с php кодом, чтобы было понятно что к чему). Запрос выдает тег по алиасу и schema.org SEO обвес:
/*
{"params":{"targetId":"integer"}}
*/
function getTagByAlias($params){
/*
Check necessary param consistency
*/
if ( !isset($params['targetId']) ) {
try {
$erLogData = new ErLogData(
'API_INTERNAL_PARAM_TRANSMISSION_ERROR',
57,
get_class($e),
__FILE__,
__CLASS__,
__FUNCTION__,
__LINE__,
date('Y-m-d H:i:s')
);
$erLog = ErLogFactory::create($erLogData, $e);
$response = json_encode(array('response' => array('error' => ['message' => $erLog->_user_message, 'code' => $erlog->_code])), JSON_UNESCAPED_UNICODE);
throw $erLog;
}
catch (ErLog $ee) {
ErLog::full_log_v1($ee);
return $response;
}
}
if ( !isset($params['tagAlias']) ) {
try {
$erLogData = new ErLogData(
'API_INTERNAL_PARAM_TRANSMISSION_ERROR',
57,
get_class($e),
__FILE__,
__CLASS__,
__FUNCTION__,
__LINE__,
date('Y-m-d H:i:s')
);
$erLog = ErLogFactory::create($erLogData, $e);
$response = json_encode(array('response' => array('error' => ['message' => $erLog->_user_message, 'code' => $erlog->_code])), JSON_UNESCAPED_UNICODE);
throw $erLog;
}
catch (ErLog $ee) {
ErLog::full_log_v1($ee);
return $response;
}
}
if ( !isset($params['getFullArticles']) ) {
try {
$erLogData = new ErLogData(
'API_INTERNAL_PARAM_TRANSMISSION_ERROR',
57,
get_class($e),
__FILE__,
__CLASS__,
__FUNCTION__,
__LINE__,
date('Y-m-d H:i:s')
);
$erLog = ErLogFactory::create($erLogData, $e);
$response = json_encode(array('response' => array('error' => ['message' => $erLog->_user_message, 'code' => $erlog->_code])), JSON_UNESCAPED_UNICODE);
throw $erLog;
}
catch (ErLog $ee) {
ErLog::full_log_v1($ee);
return $response;
}
}
if ($params['tagAlias']=='all-tags') {
$whereClause = '';
} else {
$params['tagAlias'] = "'" . $params['tagAlias'] . "'";
$whereClause = 'AND at.alias =' . $params['tagAlias'];
}
if ($params['getFullArticles']==0) {
$fullClause = '';
} else {
$fullClause = '
,article.creation_date as created,
article.html_text as content,
article.main_picture as main_picture,
article.feed_picture as feed_picture,
article.title as title,
article.subtitle as subtitle,
article."alias" as "slug",
article.creation_date as published,
article.author_id as author_id
';
}
/*
Language - setting param by default
*/
if (!isset($params['language']) ) {
$params['language'] = 1;
}
if ( !is_int($params['language']) ) {
try {
$erLogData = new ErLogData(
'LANGUAGE IS NOT INT',
58,
get_class($e),
__FILE__,
__CLASS__,
__FUNCTION__,
__LINE__,
date('Y-m-d H:i:s')
);
$erLog = ErLogFactory::create($erLogData, $e);
$response = json_encode(array('response' => array('error' => ['message' => $erLog->_user_message, 'code' => $erlog->_code])), JSON_UNESCAPED_UNICODE);
throw $erLog;
}
catch (ErLog $ee) {
ErLog::full_log_v1($ee);
return $response;
}
}
if (is_file('../Credentials/db_credentials.php')){
include '../Credentials/db_credentials.php';
}
else {
exit("No ../Credentials/db_credentials.php credentials available");
}
$credentials =
[
'host' => $host,
'db' => $db,
'user' => $user,
'pass' => $pass,
];
/*
Create a new article
*/
try {
$queryString =
"
SELECT
to_json((a)) as tag_info
FROM
(
SELECT
to_json(\"array_agg\"(b)) as tag_data,
(SELECT publication_targets.title FROM publication_targets WHERE publication_targets.\"id\" = ".$params['targetId'].") as publication_target_title,
(SELECT publication_targets.\"id\" FROM publication_targets WHERE publication_targets.\"id\" = ".$params['targetId'].") as publication_target_id
FROM
(
SELECT
raw_data.*,
(SELECT to_json(array_agg(f)) FROM (
SELECT
article_list.*,
(SELECT to_json(array_agg(e)) FROM (
SELECT
author.\"id\" as author_id,
author.alias as author_alias,
author.contact_json as author_contacts,
author.description as author_description,
author.header_picture as main_picture
FROM
author
WHERE
author.\"id\" = article_list.article_author_id
) e) as author_info
FROM
(
SELECT DISTINCT
article.\"id\" as article_id,
article.author_id as article_author_id
".$fullClause."
FROM
article_tags
JOIN article_publication ON article_tags.\"id\" = raw_data.tag_id AND article_tags.\"id\" = article_publication.tag_id AND article_publication.is_actual = 't' AND article_publication.language_id = ".$params['language']."
JOIN article ON article.\"id\" = article_publication.article_id
) as article_list
) f) as article_list,
(
SELECT
count(article.\"id\") as article_count
FROM
article_tags
JOIN article_publication ON article_tags.\"id\" = raw_data.tag_id AND article_tags.\"id\" = article_publication.tag_id AND article_publication.is_actual = 't' AND article_publication.language_id = ".$params['language']."
JOIN article ON article.\"id\" = article_publication.article_id
) as article_count,
(SELECT to_json(array_agg(f)) FROM (
SELECT DISTINCT
article.author_id as author_id,
author.alias as author_alias,
author.contact_json::TEXT as author_contacts,
author.description as author_description,
author.header_picture as main_picture
FROM
article_tags
JOIN article_publication ON article_tags.\"id\" = raw_data.tag_id AND article_tags.\"id\" = article_publication.tag_id AND article_publication.is_actual = 't' AND article_publication.language_id = ".$params['language']."
JOIN article ON article.\"id\" = article_publication.article_id
JOIN author ON author.id = article.author_id
) f) as author_list,
array_to_json(
array[
json_build_object (
'type',
'rel',
'key',
'canonical',
'content',
'spark-in.me/tag/'||raw_data.tag_alias
),
json_build_object (
'type',
'name',
'key',
'title',
'content',
raw_data.tag_title
),
json_build_object (
'type',
'name',
'key',
'description',
'content',
raw_data.tag_description
),
json_build_object (
'type',
'property',
'key',
'og:site_name',
'content',
'Spark in me'
),
json_build_object (
'type',
'property',
'key',
'og:title',
'content',
raw_data.tag_title
),
json_build_object (
'type',
'property',
'key',
'og:url',
'content',
'spark-in.me/tag/'||raw_data.tag_alias
),
json_build_object (
'type',
'property',
'key',
'og:description',
'content',
raw_data.tag_description
)
]
) as tag_meta
FROM
(
SELECT DISTINCT
\"at\".\"id\" as tag_id,
at.\"alias\" as tag_alias,
at.title as tag_title,
at.header_picture as main_picture,
at.description as tag_description,
att.title as att_title,
att.colour as att_colour,
att.description as att_description,
att.sort_order as att_sort_order
FROM
article_tags at
JOIN article_tag_types att ON at.tag_type_id = att.id
JOIN article_publication ap ON at.\"id\" = ap.tag_id AND ap.language_id = ".$params['language']."
JOIN publication_targets pt ON pt.\"id\" = ap.target_id
WHERE 1=1
AND pt.\"id\" = ".$params['targetId']."
".$whereClause."
) raw_data
ORDER BY
7 DESC
) b
) a
";
} catch (Exception $e){
try {
$erLogData = new ErLogData(
'API_QUERY_CONSTRUCTION_ERROR',
59,
get_class($e),
__FILE__,
__CLASS__,
__FUNCTION__,
__LINE__,
date('Y-m-d H:i:s')
);
$erLog = ErLogFactory::create($erLogData, $e);
$response = json_encode(array('response' => array('error' => ['message' => $erLog->_user_message, 'code' => $erlog->_code])), JSON_UNESCAPED_UNICODE);
throw $erLog;
}
catch (ErLog $ee) {
ErLog::full_log_v1($ee);
return $response;
}
}
$result = queryWrapper ($credentials, $queryString);
return $result;
}
Обработка ошибок
Делается двумя способами:
- Есть класс Erlog, который расширяет классы php для работы с ошибками, рекурсивно прокидывает ошибки и логирует их - по идее он достоин отдельной статьи, но его автор смущается, что интернет-публика из всезнающих людей не воспримет адекватно;
- Если приложение не может подключиться к базе в локальной сети, то я получаю email (при отладке приложения были проблемы, по сути не хотелось поднимать нормальное АПИ 2 раза), то такой гениальный кусок кода отправляет аларму мне:
if (!$dbconn) {
$errorString = $appName . "\r\n". date('m/d/Y h:i:s a', time()) . "\r\n". $credentials['db'] . "\r\n". $queryString . "\r\n". implode(",", $queryParamArray);
error_log($errorString, 1, "[email protected]");
$ret = file_put_contents('offline-errors.log', $errorString);
die('Could not connect (logged)');
} else {
/*
Continue executing code
*/
}
Сущности для прошивки админки
Тоже часть legacy-кода. Изначально проектировалась, чтобы позволить создавать новые странички в админке максимально быстро. По сути является просто веб-фреймворком, разобранным на зависимости.
HTML код и JS-инклюды библиотек собираются конкатенацией строк на php в большом классе из констант. Тупо, грубо, медленно, но зато работает. Естественно вся фронтенд логика сделана аяксом через одно место, т.к. на аякс и фронтенд ни знаний и усилий в свое время ну совсем не хватило.
По идее тут нет ничего интересного, кроме:
- Изначально даже была написана интеграция c АПИ redmine для отправки тикета в саппорт. По сути не пригодилось;
- У админки есть права на доступ к определенным страницам. Пользователь получает права на методы АПИ и доступ к страницам админки отдельно (они могут быть частью одной роли);
- Связь страниц и иерархия описаны в виде бинарного дерева в базе;
- У каждой страницы есть свой uuid по которому она собственно и собирается;
- Есть витиватая логика логина на php даже с минимальной криптографией;
Вот как-то так. В ближайшее время будет написано про бекапы и администрирование серверов с нуля (по сути нормальная статья на основе этого поста) . Если вам будет реально интересно - то можно даже выложить source-код, но для этого нужно его основательно почистить - а для этого нужно, чтобы это кому-то было нужно =)
Пишите в комментах, пишите в чате на канале, голосуйте на канале - будет почищенный сорс код.
Spark-in.me - как, зачем и почему. Часть 1 - почему? Spark-in.me - как, зачем и почему. Часть 2 - как? Архитектура приложения и структура БД Spark-in.me Часть 3 - DIY поддержка и админство сайта Spark-in.me Часть 4 - Базовое админство для обычных человеков (postgres и не только) Spark-in.me Часть 5 - переход на HTTPSСтатьи цикла