Spark-in.me - как, зачем и почему. Часть 2 - как? Архитектура приложения и структура БД

Или как мы лениво проектировали структуру данных для Spark-in.me на уровне БД, АПИ и business tier

Posted by snakers41 on June 7, 2017

Статьи цикла

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


Введение

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

На практике часто получается немного иначе:

  • Мы умеем то-то и у нас есть небольшая кодовая база;
  • Мы понимаем, что есть такие-то потребности в виде списка, но формализация всего по времени сопоставима с непосредственной имплементацией;
  • Поэтому лучше планирование и исполнение заложить в несколько коротких итераций;
  • Готовые решения не нравятся по причине отсутствия каких-либо фич или отсутствия владения контентом;

 

Сразу скажу, что с точки зрения дизайна самого приложения / архитектуры я частично вдохновлялся такими примерами:

  1. Админка медузы и сама медуза как самое прогрессивное с технической точки зрения СМИ (про контент молчу, он везде одинаковый);
  2. Тем, как работала публикация на моем прошлом месте работы;
  3. Принципом максимального отсутствия бизнес-логики в business tier и на клиенте - по сути большая часть новых вещей просто берется из базы и прокидывается в АПИ и на морду (зачем плодить уровни, если я один и контент простой?);
  4. Не возьмусь вспоминать где это сделано лучше всего, но все SEO фичи должны быть с самого начала (RSS, Sitemap, schema.org/ld+json, og-теги, мета-теги);

 

Из этого небольшого списка следуют такие принципы:

  1. Должно поддерживаться много авторов;
  2. Создание статей делается в простом WYSIWYG редакторе, картинки заливаются отдельно на страничке;
  3. Контент должен быть максимально отделен от формы, и желательно представим во всех возможных форматах без проблем (на практике, остался формат статей HTML, который всеяден, канал в телеграме, его веб-трансляция, и email-подборки статей с канала и сайта. От идеи мультипостинга я отказался, т.к. там слишком высокие пороги на вход и поддержку тучи социальных аккаунтов, хотя технически можно это сделать это хоть сейчас - собственно идея мультипостинга и породила мысль о том, чтобы использовать имеющуюся кодовую базу в таком формате); 
  4. Контент должен быть представлен в такой форме, чтобы любой в интернете мог им поделиться, и при этом автоматически бы подтягивались open-graph теги;
  5. Нужно хранить минимально нужное количество SEO сущностей в базе, чтобы не пришлось потом их разделять;
  6. Принцип расширяемости и отделимости каждого компонента приложения от другого. Не совсем соблюдается, т.к. очень много завязано на PostgreSQL;
  7. Каждая единица контента должна иметь уникальный URL (главная, авторы, теги, статьи, фиксированные страницы). При этом никто не мешает делать автоматизированные подборки (сейчас есть теги и группы тегов + страницы авторов, по идее надо делать также страницы групп тегов);
  8. Добавление любой, даже самой лютой фичи, должно сводиться к простейшему набору из действий:
    1. Набор таблиц;
    2. Набор методов АПИ;
    3. Набор предобработок по крону или операций с данными (пока не нужно);
    4. Прокидывание в админку или на морду;

 

Несложно увидеть, что современные решения типа Medium, Tumblr или менее современные типа Wordpress не совсем этим принципам удовлетворяют. Самая главная проблема таких систем  - по сути вы не владеете своим контентом. Я сталкивался с несколькими open-source минималистскими приложениями для блогов, которые в принципе можно использовать, но если они написаны на языке, который вам незнаком, то добавление, допустим, блока "похожие статьи" может стать путешествием в ад.

Имея эти требования в голове, выбор на такие технические решения:

  1. База PostgreSQL - возможно самая продвинутая open-source база в мире, которая сочетает в себе как реляционную базу так и много нереляционных фич;
  2. PHP для бекенда админки и для АПИ как самый простой, документированный и быстрый в применении и освоении инструмент;
  3. ReactJS для морды (хотя можно было бы на PHP все сделать). Изначально предполагалось сделать второй фановый проект, где нужны были приложения через React Native, но он отвалился;

 

Давайте теперь пройдемся по каждому компоненту отдельно. Я понимаю, что для части вещей сделанное может показаться overkill'ом, но часть этой кодовой базы у меня просто была, часть я дописывал с целью сделать тот же мультипостинг и иметь "правильное" АПИ и прошивку.

Статьи

 

  1. Изначально планировалось, что будет мульти-постинг, поэтому тут есть ключ для публикации и цели публикации. На практике используется только публикация на сайт (совершенству нет предела, но никто из нас, как мне кажется, не будет поддерживать паблики ВК и ФБ);
  2. Статья - основная сущность, она публикуется в теги на определенном языке;
  3. У тегов есть типы тегов;
  4. Также глобальные переменные хранятся в виде json в таблице;
  5. Автор имеет пользователя, от имени которого он входит в админку;
  6. Обратите внимание, что основные SEO-сущности хранятся в таблицах;

 

 

Пользователи, права и сессии пользователей

 

 

 

 

 

  1. У пользователя есть роли, которые объединяются в права. По сути получается, что любой пользователь может иметь любые права через роли;
  2. Проверка доступности методов делается по статической сессии;
  3. Для пользователей сайта статическая сессия создается заранее, пользователи работают по ключу (обратите внимание, я знаю про разные криптографические методы, динамические сессии, токены, protobuf и прочее - но я не использую это для простоты приложения - это же информационный контент, зачем его прятать?);
  4. При входе в админку у автора создается сессия и прописывается в cookies. При повторном заходе не нужно вводить пароль, если в браузере есть кука;

 

 

 

 

Методы АПИ

 

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

  1. Поддерживалась версионность методов АПИ с логами прошлых версий, чтобы не возиться с поддержкой;
  2. Добавление метода АПИ сводилось к:
    1. Внесению записей в таблицы и созданию прав;
    2. Прописыванию текста фиксированного запроса в таблицу или создании специальной кастомной функции;
  3. По сути структура выше описывает хранимые php-функции АПИ и тексты простых функций АПИ в виде запросов, хранимых в базе;
  4. Да, в базе хранятся права доступа к этой же базе. Но запросы с такими данными ходят только в локальной сети, поэтому это не должно быть проблемой;
  5. Значительная часть логики тут разнесена так:
    1. Большой класс apiHandler.php, который принимает внешние запросы, проверяет их на валидность, форматирование, верные параметры и выбрасывает ошибки, как в логи так и обратно;
    2. Большой класс apiExecutor.php, который в случае наличия валидного запроса этот запрос исполняет. Нетрудно догадаться, что изначально между этими двумя сущностями планировался балансировщик нагрузки, который просто пока не нужен;
  6. Работа с ошибками вынесена в отдельные классы и сущности, которые будут описаны ниже;
  7. Тут используются функции PostgreSQL по работе с json - многие конфиги и параметры хранятся в виде json, чтобы обеспечить развиваемость и обратную совместимость (и отсутствие геморроя для меня =) );
  8. В общем случае работу АПИ можно представить так:
    1. Приходит запрос. Проходит простейший фаервол (все ненужные порты закрыты);
    2. В виде большой древовидной структуры запрос парсится и проверяется на:
      1. Валидность типа запроса и его содержимого;
      2. Валидность типов параметров и их содержимого;
      3. Наличие обязательных параметров;
      4. Наличие прав у запрашивающего;
    3. Если все проверки пройдены, в локальной сети отправляется запрос в apiExecutor.php (разделение сделано было в legacy коде с определенной целью), который делает ряд проверок, смотрит где найти запрос для функции или функцию на php, которую нужно исполнить;
    4. Естественно везде стоят проверки на SQL-инъекции, проверяется искейпинг и прочее;
    5. Функции, которые хранятся в виде запросов, естественно имеют экранированные параметры; 

 

Пример параметров одного из запросов АПИ - я не большой фанат технических извращений, по мне простой json внутри POST запросов нормально работает.


{  
	"key": "very_secret_key",
	 "method": {
	     "name": "getTagByAlias",
	      "version": 1  
	      },
	      "params": {
	          "targetId": 2,
	           "tagAlias": "not-buying-bs",
	            "getFullArticles": 1  
	      }
}

 

 

Статистика по вызовам запросов АПИ

textcount
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