-Поиск по дневнику

Поиск сообщений в rss_rss_hh_new

 -Подписка по e-mail

 

 -Статистика

Статистика LiveInternet.ru: показано количество хитов и посетителей
Создан: 17.03.2011
Записей:
Комментариев:
Написано: 51


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

Понедельник, 26 Июня 2017 г. 07:01 + в цитатник
image
Статьи in a nutshell

Описав зачем и почему мы решились сделать свой фан-проект можно попробовать начать описывать как мы делали конкретные вещи.

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

Статьи цикла
  1. Spark-in.me. Часть 1 — Зачем и почему?
  2. Spark-in.me. Часть 2 — Архитектура приложения и структура БД
  3. Spark-in.me. Часть 3 — DIY поддержка и админство сайта
  4. Spark-in.me. Часть 4 — Базовое админство для обычных человеков
  5. Spark-in.me. Часть 5 — Переход на HTTPS
  6. Spark-in.me. Часть 6 — Исходный код и настройка бекенда
  7. Spark-in.me. Часть 7 — Исходный код и настройка фронтенда




0 Введение



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

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


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

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


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

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

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


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

1 Статьи


image

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


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

image

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


3. Методы АПИ


image

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

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


В общем случае работу АПИ можно представить так:

  • Приходит запрос. Проходит простейший фаервол (все ненужные порты закрыты);
  • В виде большой древовидной структуры запрос парсится и проверяется на:
    1. Валидность типа запроса и его содержимого;
    2. Валидность типов параметров и их содержимого;
    3. Наличие обязательных параметров;
    4. Наличие прав у запрашивающего;
  • Если все проверки пройдены, в локальной сети отправляется запрос в 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  
	      }
}


Статистика по вызовам запросов АПИ на момент написания статьи
  • 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;	

}		



4. Обработка ошибок


image

Делается двумя способами:
  • Есть класс 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, "aveysov@gmail.com");

            $ret = file_put_contents('offline-errors.log', $errorString);

            die('Could not connect (logged)');

        } else {
            /*
            Continue executing code
            */
        }


5. Сущности для прошивки админки


image

Тоже часть legacy-кода. Изначально проектировалась, чтобы позволить создавать новые странички в админке максимально быстро. По сути является просто веб-фреймворком, разобранным на зависимости.

HTML код и JS-инклюды библиотек собираются конкатенацией строк на php в большом классе из констант. Тупо, грубо, медленно (грузится 1-2 секунды), но зато работает. Естественно вся фронтенд логика сделана аяксом через одно место, т.к. на аякс и фронтенд ни знаний и усилий в свое время ну совсем не хватило.

По идее тут нет ничего интересного, кроме:

  • Изначально даже была написана интеграция c АПИ redmine для отправки тикета в саппорт. По сути не пригодилось;
  • У админки есть права на доступ к определенным страницам. Пользователь получает права на методы АПИ и доступ к страницам админки отдельно (они могут быть частью одной роли);
  • Связь страниц и иерархия описаны в виде бинарного дерева в базе;
  • У каждой страницы есть свой uuid по которому она собственно и собирается;
  • Есть витиватая логика логина на php даже с минимальной криптографией;


Пишите в комментариях, если какая-то часть покажется вам достойной пошагового разбора.
Original source: habrahabr.ru (comments, light).

https://habrahabr.ru/post/331578/

Метки:  

 

Добавить комментарий:
Текст комментария: смайлики

Проверка орфографии: (найти ошибки)

Прикрепить картинку:

 Переводить URL в ссылку
 Подписаться на комментарии
 Подписать картинку