Пример разработки простого блога на CleverStyle CMS

в 19:06, , рубрики: cmf, cms, php, метки: , ,

Давно удивляюсь, как, бывает, усложняют разработку современные фреймворки. Конечно, у меня нет права сказать, что они плохие, но и хорошими я их называть не могу. А всё вот почему: их цель — упростить и ускорить разработку, а так же каким-то образом стандартизировать и структурировать проект. Но, по моему скромному субъективному мнению, с первой половиной порой получается прямо противоположный эффект, пишется много кода, который сам по себе ничего не делает, а только обслуживает основной код. Эта статья — пример иного подхода к задаче разработки простого блога, используя не Zend Framework 2, как это сделал rrromka, а собственную разработку CleverStyle CMS.

Немного истории

Разработкой CleverStyle CMS я занимаюсь уже 3 года. Необходимость возникла как раз из-за сложности и неудобства того, с чем я пытался работать. Разработка ведется в свободное время, а так же параллельно с проектами, которые используют CMS (добавляется та функциональность, которой не хватает). Это значит, что добавляются на самом деле нужные функции, под которые есть задачи, а так же именно в том виде, в котором они будут наиболее удобны в использовании. Основная идея — сделать работу очевидных вещей автоматической, а не совсем очевидных — предельно простой, при этом всегда есть возможность повлиять на стандартное поведение и скорректировать его любым нужным образом. Ну и последнее — огромная благодарность хабрасообществу за конструктивную критику прошлой статьи: вы помогли мне пересмотреть свои взгляды на некоторые вещи, стать лучшим программистом и изменить CleverStyle CMS в лучшую сторону.

Окружение

Очевидно, что нужен веб-сервер и БД с реквизитами доступа.
Для установки не нужен composer или ещё какие-то инструменты, даже архиватором пользоваться не нужно. Бросаете дистрибутив в корень будущего сайта и открываете его через браузер. Получаете такое окошко:
image
Заполнив все поля, получаете готовое к использованию окружение. Дистрибутив сам себя распакует, выставит настройки по умолчанию где нужно и удалит сам себя в целях безопасности. Уже начиная с этого этапа можно заметить простоту, не нужно копировать несколько десятков тысяч мелких файлов по ftp/ssh.

Структура будущего модуля

Со структурой системы можно познакомиться в wiki, но мы будем знакомиться с используемыми частями по ходу статьи.
Сам модуль будет состоять с класса Posts, который будет оберткой над БД и будет предоставлять простой интерфейс для управлением постами. Так же будут собственно страницы, доступные пользователю, API, и в конце концов будет несколько мета-файлов, которые будут объяснять движку некоторые детали работы и позволят собрать модуль в самостоятельно распространяемый дистрибутив.
Для начала создадим директорию components/modules/MyBlog для нашего будущего модуля.

БД

Пост в блоге будет иметь название, содержимое, автора и дату написания. Как приверженец графических инструментов — готовлю структуру в PhpMyAdmin и экспортирую:

CREATE TABLE IF NOT EXISTS `[prefix]myblog_posts` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `user` int(10) unsigned NOT NULL,
  `title` varchar(1024) NOT NULL,
  `text` text NOT NULL,
  `date` bigint(20) unsigned NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Префикс таблицы заменен на [prefix] для универсальности, CMS подставит вместо него нужный сама.
Создаем файл components/modules/MyBlog/meta/install_db/posts/MySQLi.sql и вставляем туда полученный SQL. Таким образом, при установке модуля в админке будет создана необходимая таблица в БД. Аналогично создадим файл components/modules/MyBlog/meta/uninstall_db/posts/MySQLi.sql:

DROP TABLE `[prefix]myblog_posts`;

MySQLi — название движка БД, он пока единственный,
posts — произвольное название, с которым ассоциируется БД (может быть несколько для разных целей, вплоть до того, что часть таблиц будет на одном сервере в MySQL/MariaDB, а вторая — на другом сервере в PostgreSQL).
Название posts пропишем в components/modules/MyBlog/meta/db.json:

[
	"posts"
]

На этом с БД всё. Таблица будет создаваться при установке модуля и удаляться при удалении модуля.

Класс Posts

Класс разместим в файле components/modules/MyBlog/Posts.php и поместим в пространство имен csmodulesMyBlog — это позволит CMS найти его в случае необходимости.

Скрытый текст

namespace	csmodulesMyBlog;
use			csConfig,
			csCachePrefix,
			csDBAccessor,
			csLanguage,
			csUser,
			csSingleton;

/**
 * Class Posts for posts manipulation
 *
 * @method static csmodulesMyBlogPosts instance($check = false)
 */
class Posts extends Accessor {
	use Singleton;

	/**
	 * Cache object instance
	 *
	 * @var Prefix
	 */
	protected $cache;

	protected function construct () {
		/**
		 * Save instance of cache object with prefix MyBlog (will be added to every item)
		 */
		$this->cache	= new Prefix('MyBlog');
	}
	/**
	 * Required by abstract Accessor class
	 *
	 * @return int	Database index
	 */
	protected function cdb () {
		return Config::instance()->module('MyBlog')->db('posts');
	}
	/**
	 * Get post
	 *
	 * @param int|int[]		$id
	 *
	 * @return array|bool
	 */
	function get ($id) {
		if (is_array($id)) {
			foreach ($id as &$i) {
				$i	= $this->get($i);
			}
			return $id;
		}
		$id	= (int)$id;
		/**
		 * Try to get item from cache, if not found - get it from database and save in cache
		 */
		return $this->cache->get("posts/$id", function () use ($id) {
			if ($data = $this->db()->qf([	//Readable database, Query, Fetch
				"SELECT
					`id`,
					`user`,
					`title`,
					`text`,
					`date`
				FROM `[prefix]myblog_posts`
				WHERE `id` = '%d'
				LIMIT 1",
				$id
			])) {
				$L					= Language::instance();
				$data['datetime']	= $L->to_locale(date($L->_datetime_long, $data['date']));
				$data['username']	= User::instance()->username($data['user']);
			}
			return $data;
		});
	}
	/**
	 * Add post
	 *
	 * @param string	$title
	 * @param string	$text
	 *
	 * @return bool|int			Id of created post or <b>false</b> on failure
	 */
	function add ($title, $text) {
		$user	= User::instance()->id;	//User id
		$title	= xap($title);			//XSS filter
		$text	= xap($text, true);		//XSS filter, allow html tags
		$date	= TIME;					//Current timestamp
		if ($this->db_prime()->q(		//Writable database, Query
			"INSERT INTO `[prefix]myblog_posts`
				(
					`user`,
					`title`,
					`text`,
					`date`
				) VALUES (
					'%d',
					'%s',
					'%s',
					'%d'
				)",
			$user,
			$title,
			$text,
			$date
		)) {
			/**
			 * Delete total count of posts
			 */
			unset($this->cache->total_count);
			return $this->db_prime()->id();
		}
		return false;
	}
	/**
	 * Edit post
	 *
	 * @param int		$id
	 * @param string	$title
	 * @param string	$text
	 *
	 * @return bool
	 */
	function set ($id, $title, $text) {
		$id		= (int)$id;
		$title	= xap($title);			//XSS filter
		$text	= xap($text, true);		//XSS filter, allow html tags
		if ($this->db_prime()->q(		//Writable database, Query
			"UPDATE `[prefix]myblog_posts`
			SET
				`title`	= '%s',
				`text`	= '%s'
			WHERE `id` = '%d'
			LIMIT 1",
			$title,
			$text,
			$id
		)) {
			/**
			 * Delete cached item if any
			 */
			unset($this->cache->{"posts/$id"});
			return true;
		}
		return false;
	}
	/**
	 * Delete post
	 *
	 * @param int	$id
	 *
	 * @return bool
	 */
	function del ($id) {
		$id	= (int)$id;
		if ($this->db_prime()->q(
			"DELETE FROM `[prefix]myblog_posts`
			WHERE `id` = '%d'
			LIMIT 1"
		)) {
			/**
			 * Delete cached item if any, and total count of posts
			 */
			unset(
				$this->cache->{"posts/$id"},
				$this->cache->total_count
			);
			return true;
		}
		return false;
	}
	/**
	 * Get posts
	 *
	 * @param $page
	 *
	 * @return int[]
	 */
	function posts ($page = 1) {
		$from	= ($page - 1) * 10 ?: 0;
		return $this->db()->qfas(	//Readable database, Query, Fetch, Single, Array
			"SELECT `id`
			FROM `[prefix]myblog_posts`
			ORDER BY `id` DESC
			LIMIT $from, 10"
		) ?: [];
	}
	/**
	 * Get total count of posts
	 *
	 * @return int
	 */
	function total_count () {
		return $this->cache->get('total_count', function () {
			return $this->db()->qfs(	//Readable database, Query, Fetch, Single
				"SELECT COUNT(`id`)
				FROM `[prefix]myblog_posts`"
			) ?: 0;
		});
	}
}

Класс является оберткой над БД с кэшированием постов и их общего количества. Это класс одиночка, и имеет такие публичные методы:

  • get
  • add
  • set
  • del
  • posts
  • total_count

Сам класс не занимается проверкой прав доступа, а только корректностью вводимых данных.

БД используется как в режиме чтения, так и в режиме записи

в данном примере это не важно, но изначальное разделение операций позволит при использовании репликации использовать основную БД для записи, а остальные для чтения; это небольшое разделение сделано с перспективой

Код достаточно просто написан и хорошо прокомментирован, а так же хорошо подхватывается IDE.

Интерфейс пользователя

В общем случае для маршрутизации используется простая json структура, которая описывает адреса страниц внутри модуля. Создадим файл components/modules/MyBlog/index.json который опишет маршрутизацию пользовательской части:

{
	"list"	: [],
	"post"	: [
		"view",
		"add",
		"edit",
		"delete"
	]
}

Таким образом, пути будут выглядеть так:

  • MyBlog/list
  • MyBlog/list/{page}
  • MyBlog/post/add
  • MyBlog/post/view/{id}

Соответственно, в директории модуля создаем следующую структуру файлов:

  • list.php
  • post/add.php
  • post/delete.php
  • post/edit.php
  • post/view.php

Благодаря тому, что они описаны выше — CMS их вызовет на соответствующих страницах.
Пример файла list.php:

Скрытый текст

namespace	csmodulesMyBlog;
use			csConfig,
			csPage,
			h;
$rc				= Config::instance()->route;
$page			= 1;
if (isset($rc[1]) && $rc[1]) {
	$page	= (int)$rc[1];
}
$Page			= Page::instance();
$Posts			= Posts::instance();
$total_count	= $Posts->total_count();
$Page->content(
	h::{'a.cs-button-compact'}(
		h::icon('plus').' Добавить пост',
		[
			'href'	=> 'MyBlog/post/add'
		]
	)
);
if (!$total_count) {
	$Page->content(
		h::{'p.cs-center.uk-text-info'}('Пока нет постов')
	);
	return;
}
$Page->title('Мой блог');
if ($page > 1) {
	$Page->title("Страница $page");
}
$Page->content(
	h::{'section article.cs-myblog-posts'}(
		h::{'h1 a[href=MyBlog/post/$i[id]]'}('$i[title]').
		h::div('$i[text]').
		h::footer('$i[datetime], $i[username]'),
		[
			'insert'	=> $Posts->get($Posts->posts($page))
		]
	).
	(
		$total_count > 10 ? h::{'div.cs-center'}(pages($page, ceil($total_count / 10), function ($page) {
			return $page < 2 ? 'MyBlog' : "MyBlog/list/$page";
		})) : ''
	)
);

Пространство имен у нас одно и то же практически во всех файлах модуля.
Config::instance()->route — позволяет получить индексированный массив элементов пути страницы без учета названия модуля. В данном случае используется для того, чтобы определить какую страницу открывает пользователь, например для MyBlog/list/3 мы получим массив ['list', 3]. В целом, упомянутые выше файлы являют собой представления + проверку прав доступа.

API

Да, внешний API мы в самом модуле использовать не будем, но всё же сделаем его (может кому-то пригодится). Сделаем самое простое — управление конкретными постами (например, для редактирования постов без перезагрузки страницы). Создадим в components/modules/MyBlog/api несколько файлов:

  • index.delete.php
  • index.get.php
  • index.post.php
  • index.put.php

Думаю, названия файлов рассказывают о себе достаточно. В самом простом случае у нас нет никакой структуры и index.json нам не нужен — мы просто создаем пачку index файлов для каждого типа запроса DELETE/GET/POST/PUT и CMS найдет эти файлы сама. Суффиксы можно использовать в API аналогичным образом и при вложенной структуре с index.json. Пример запроса к API:

POST api/MyBlog
{
«title»: «Blog post title»,
«text»: «Blog post content»
}

Можно отправлять JSON, если не забыть указать Content-type: application/json.
В ответ придет либо:

201 Created

{
«id»: «5»
}

Либо код и сообщение об ошибке.
Вот файл index.post.php, который этим занимается:

namespace	csmodulesMyBlog;
use			csPage;
if (!isset($_POST['title'], $_POST['text'])) {
	error_code(400);
	return;
}
if ($post = Posts::instance()->add($_POST['title'], $_POST['text'])) {
	code_header(201);
	Page::instance()->json([
		'id'	=> $post
	]);
} else {
	error_code(500);
}

Page::instance()->json() позволяет отправлять данные как они есть (например массивы), а метод уже сам сделает JSON строку и добавит нужные заголовки. То же самое на счёт error_code(), просто передайте код ошибки — всё остальное будет сделано автоматически.

Напоследок

Для того, чтобы собрать установочный дистрибутив нашего модуля создадим файл components/modules/MyBlog/meta.json с некоторой служебной информацией:

{
	"package"				: "MyBlog",
	"category"				: "modules",
	"version"				: "0.0.2",
	"description"			: "Simple demo blog module",
	"author"				: "Nazar Mokrynskyi",
	"website"				: "cleverstyle.org/cms",
	"license"				: "MIT License",
	"db_support"			: [
		"MySQLi"
	],
	"provide"				: "myblog",
	"optional"				: [
		"editor"
	],
	"languages"				: [
		"Русский"
	]
}

Как положено — добавим components/modules/MyBlog/license.txt, а так же components/modules/MyBlog/prepare.php и components/modules/MyBlog/languages/Русский.json для того, чтобы сделать красивым заголовок страницы поста и локализировать название модуля.
Ну вот, когда всё готово — берем из репозитория CleverStyle CMS и добавляем себе в корень сайта:

  • build
  • install
  • build.php
  • install.php

Добавляем в .htaccess несколько строчек (чтобы движок не перехватывал обращения к этому файлу):

<Files build.php>
RewriteEngine Off
</Files>

Переходим по адресу build.php, выбираем Module, в списке выбираем MyBlog, кликаем Build.
В корне сайта получаем файл MyBlog_0.0.2.phar.php, который можно использовать для установки на другой копии системы.

Что можно ещё сделать

Если нужна стилизация постов:

  • создать components/modules/MyBlog/includes/.htaccess

    Allow from all
    RewriteEngine Off

  • положить в components/modules/MyBlog/includes/css нужные файлы стилей, система их подхватит (аналогично JavaScript в components/modules/MyBlog/includes/js) и
    обработает

    файлы в production объединяются и сжимаются с помощью gzip, а в css файлах дополнительно очищаются комментарии, и встраиваются включения такие как импортированные css стили, шрифты, изображения, таким образом всё находится в одном файле

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

Автор: nazarpc

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js