Я работаю программистом более 5 лет (web), и хотел бы поделиться методикой, которая экономит силы, время и помогает автоматизировать процесс проектирования.
Методика основана на объектно-ориентированном проектировании, но несколько необычна. Зато имеет очевидные плюсы:
— в идеале, программирование по CORE сводится к описанию задачи (код близок к бизнес-логике)
— чётко разделяет систему на слабосвязанные компоненты
— легко автоматизируема, позволяет генерировать осмысленный код
Почему методика называется CORE и как это расшифровывается? Отчасти потому, что у меня тяга к красивым названиям. По буквам:
Context — контекст вычислений (что инициировало вычисления)
Object — объект, который производит вычисления
Request — действие, которое нужно совершить, чтобы объект смог продолжить вычисления
Event — событие, которое происходит с объектом
Плюсы по сравнению со стандартными способами разработки:
— ускорение стадии проектирования за счёт формализованной схемы проектирования
— ускорение стадии разработки за счёт умной генерации кода
— автоматизация создания юнит-тестов
— неглючная реализация бизнес-логики практически любой сложности
— простая поддержка кода
— простота совместного владения кодом
Минусы стандартных способов по сравнению с CORE:
— часто нельзя окинуть одним взглядом всю часть системы
— нужно самому продумывать когда и в каком месте будут вызываться обработчики тех или иных действий. CORE разруливает вызовы автоматически.
— часто вводятся дополнительные уровни абстракции, не связанные с бизнес-логикой, чтобы реализовать её особенности. в CORE это не нужно.
— программист часто совершает кучу однообразных действий, которые легко можно автоматизировать
— юнит-тестирование внедрять тяжелее
Немного теории.
Связанность кода, модульность и поток исполнения
В программировании всё банально и просто: если мы находимся в точке исполнения, значит нас кто-то вызвал.
o WebDispatcher o ArticleController o ArticleMapper o DB / o / o o WidgetManager o WidgetForNovice / o o WidgetForExpirienced / o / o o Stat / o / o o ViewRenderer / o
Так организуется связанность кода. Однако, когда мы хотим построить модульную систему, мы вступаем в противоречие: вдруг нам становится необходимо, чтобы низкоуровненные классы вызывали методы классов высокого уровня, образуются места, где скапливается куча логически несвязанного между собой кода… И вот, модульность теряется: код становится разбросан по разным местам проекта. Если нам захочется добавить на страницы виджеты не трогая контроллер (например, в порядке эксперимента на 10% пользователей), у нас ничего не выйдет — нам обязательно нужно будет себя куда-то прописать.
А что, если мы хотим оставить модульную структуру, и мы хотим, чтобы код, относящийся к одной задаче, находился в одном месте? Но ведь, модулю, например, статистики, нужно, чтобы его кто-то вызвал?
Попробуем классифицировать возможные типы вызовов:
— я вызывающая сторона логически «знает» вызываемый класс и вызывает его потому что это является частью её логики действий (например, маппер знает как вызвать базу данных и как с ней работать — фактически, он знает о базе данных всё)
— вызывающая сторона логически «не знает» вызываемый класс, но вызываемому классу обязательно нужно, чтобы его вызывали (пример: контроллеру для успешной работы не нужна статистика, но статистике обязательно нужно, чтобы её вызвали из контроллера, потому что иначе она не будет работать)
— вызывающая сторона не знает, кого вызвать, но ей надо, чтобы что-то было сделано (пример: WidgetManager не знает, какой из WidgetForNovice и WidgetForExpirienced будет показан, но ему нужно, чтобы обязательно был отображён виджет подходящий под условия)
Как на это посмотреть с практической стороны,
— классу DB совершенно всё равно, кто его будет использовать — mapper или кто-то ещё — он не привязан к логике
— Mapper, наоборот, очень зависит от базы данных, он завязан на неё, и если вместо MySQL ему подсунуть Redis, он сломается
— классу ArticleController, по большому счёту, наплевать, что ему нужно по пути дёрнуть статистику — это никак не скажется на отображении данных, которое ему поручено его обязанностями
— классу Stat наоборот хочется знать, из каких источников он собирает данные — он должен знать всё о своих источниках, чтобы правильно работать
— классу WidgetManager неважно сколько в системе зарегистрировано виджетов, но важно, чтобы кто-нибудь из них отобразился
— классам виджетов важно, проверить, что они могут отобразиться и отобразиться таки — они и созданы, чтобы был вспомогательными к WidgetManager-у
По типу зависимости компоненты (модули) можно также поделить на три вида:
— компоненты, зависимые напрямую (маппер зависит от базы данных, он использует её для своей работы)
— компоненты, требующие входящих вызовов (статистика зависит от того, дернет ли её контроллер)
— компоненты, требующие разрешения исходящих вызовов (WidgetManager не сможет отработать, если некому будет делегировать)
Для разрешения зависимостей при сохранении модульности ничего не остаётся, кроме использования объектов-посредников, которые будут создавать связность. А внешние вызовы (хотя и более абстрактные), всё равно придётся совершать. Таким образом, модули могут предоставлять ресурс вызова.
По типу предоставляемых ресурсов компоненты можно поделить на два вида:
— компоненты, осуществляющие внешние вызовы (ArticleController обязательно должен вызвать кучу классов)
— компоненты, не осуществляющие внешние вызовы (статистике, например, просто нечего вызывать)
В системе CORE я предлагаю предоставлять события Events (подписавшись на которое, можно попасть в поток исполнения) и Requests (для случаев, когда вызывающий объект ожидает выполнения действия, но не знает о том, кто будет его совершать).
Почему Events и Requests? Эти абстракции хорошо сочетаются с бизнес-логикой — практически любую бизнес-логику можно разложить на события («когда что-то произошло, сделать что-то ещё») и запросы («модулю нужно, чтобы произошло вот это» без уточнения что и когда будет это имплементировать).
Если мы описываем бизнес-логику в терминах Events и Requests, мы автоматически получаем реализацию нужной нам логики. И легко проконтролировать, что уже было реализовано, а что нет.
Принципы хорошо структурированного программного кода
Хорошо, если при программировании мы соблюдаем следующие принципы:
— слабая связанность
Часто при работе обычными методиками возникают классы-помойки, которые знают о десятках других классов в системе. Со временем разобраться в таком коде очень и очень сложно, а рефакторить заставляют самых провинившихся/самых ответственных членов команды.
— избегать обратных зависимостей
Это происходит, когда объекты низкого уровня, вызывают объекты высокого уровня. Например, какой-нибудь WebDispatcher, в котором напрямую прописаны вызовы к Page или классам уровня бизнес-логики. Потому что когда-то так было удобно, а про рефакторинг забыли. Ну да пофиг, работает ведь.
— возможность повторного использования кода
Правильные зависимости это очень важно — попробуйте перенести класс WebDispatcher с высокоуровненными вызовами из одного проекта в другой. Чую, это будет непросто…
Идею повторного использования часто понимают неправильно, разбивая класс на очень маленькие подклассы с одним-двумя методами внутри, а потом создавая десяток-другой маленьких объектов и скармливая его целевому классу. На самом деле, это настоящая пытка — про увеличение количества уровней абстракции я ещё скажу слово.
— реализация ближе к бизнес-логике
Проблема «протекающих абстракций» заставляет нас перепроектировать всё заново при добавлении к задаче дополнительных условий. А чаще просто вставлять костыли в самые неожиданные места программы. По-быстрому, «чтобы работало».Если мы изначально проектируем в терминах предметной области, мы избавлены от этих проблем, а код при любом изменении остаётся чистым и прозрачным.
— слишком много уровней абстракции не на пользу
Когда у нас в системе возникает десятки, а то и сотни маленьких классов, про которые интуитивно непонятно что они делают (а некоторые ещё и похожи), дело гораздо серьёзнее, чем подготовка к экзамену по высшей математике.
Надо помнить, что программируют головой, а когда голова перегружена кучей мелочей, время программиста тратится впустую. Лучше бы программист использовал время на написание тестов.
— хорошо разбивать код на компоненты
Компоненту легче понять. Компоненту легче тестировать. С компонентной структурой проще разбивать проект на части и организовывать работу в небольших группах. Дальше не буду комментировать, тут и так всё понятно.
— код должен быть готов для юнит-тестирования
Перекликается с предыдущим пунктом, с одним отличием. Внутри компоненты (а она может быть сложной) мы должны иметь возможность внедрять юнит-тесты.
Также, добавлю парочку от себя:
— автоматизация это клёво — программист должен работать головой, а не руками
Больно осознавать, что мы часто делаем вручную то, что могли бы легко автоматизировать. Это качается всего — проектирования, программирования, продумывания структуры классов, поиск узких мест. Это всё можно поручить компьютеру.
— кодогенерация это клёво — избавляет от тупых ошибок и помогает формализовать процесс
Много тупых ошибок совершается, когда неправильно назовёшь переменную или метод. Ошибка может быть в одной букве, и это очень обидно. Кодогенерация избавляет от достаточно большого процента таких ошибок.
А если учесть, что мы в кодогенерацию можем включить ещё и генерацию тестов… ммм… такое программирование мне кажется намного честнее, чем поделки «наскоряк» в дедлайн.
Надо отметить, что я говорю не про генерацию пустого класса с пустыми методами, а структуры классов с заготовками кода и заготовками тестов. Это делается на основе формального описания решения задачи перед программированием (в данном случае, в виде xml-файла).
Про формализацию процесса — процесс можно разбить на два этапа — 1. проектирование, результатом которого является формальное описание алгоритма (тот самых xml-файл), 2. генерация полуфабрикатов всех классов, которые отдаются на конечную реализацию девелоперам. Экономию человеко-часов, думаю, объяснять не надо. Плюсом является тот самый xml-файл, который содержит структуру классов и описание решения задачи в краткой форме.
К практике
Итак, попробуем решить абстрактную практическую задачу из жизни любого развивающегося веб-проекта.
Задачу возьмём не слишком тривиальную, ломающую красивую структуру кода при классической организации MVC-модели: «провести эксперимент: для 50% пользователей из Москвы в возрасте от 27 до 35 лет на 5-й открытой странице в сессии показать попап, призывающий покупать внутриигровую валюту, и собрать статистику по изменению средней длины сессии (время на сайте/просмотры) и увеличению общих продаж на уникального посетителя».
Формализуем задачу:
Показ попапа:
— выделяем пользователей от 27 до 35 лет, назовём группу TargetGroupMsk27to35
— разобъём пользователей на группы A и B (тестируемая и контрольная группы)
— когда пользователь зашёл в игру, для группы A отсчитываем 5-ую страницу, назовём это событие «5-ый просмотр в группе A» (GroupMsk27to35TargetView)
— когда наступило событие GroupAView, показываем нужный попап
Статистика по сессии:
— когда началась сессия у TargetGroupMsk27to35, засекаем время её начала
— когда закончилась сессия у TargetGroupMsk27to35 замеряем время её конца и кладём в статистику
— когда происходит PageView у пользователя в группе TargetGroupMsk27to35, инкрементируем счётчик просмотров
— когда закончилась сессия у TargetGroupMsk27to35, забираем значение из счётчика просмотров и кладём в статистику
Статистика монетизации:
— когда происходит покупка пользователем из TargetGroupMsk27to35, кладём значение в нашу выделенную статистику
Отдельно отметим, что формулировка «показываем нужный попап» довольно абстрактна, поэтому формализуем:
— когда требуется показать попап PopupMsk27to35, берём его из файлов PopupMsk27to35.tpl, PopupMsk27to35.css и PopupMsk27to35.js
Как видим, наша бизнес-задача легко разложилась по терминам CORE:
Контексты: веб-запрос, скрипт определения конца сессии
Объекты: эксперимент ExperimentMsk27to35, попап PopupMsk27to35, статистика StatMsk27to35
События: PageView, UserStartSession, UserEndSession, UserBuyMoney, GroupMsk27to35TargetView
по формальному описанию генерируем код:
// далее следует псевдокод, близкий к php
class ExperimentMsk27to35 {
function isOn() {
return Config::get('ExperimentMsk27to35_enabled'); // включаем из админки
}
function inTargetGroup(User $User) {
return $User->getAge() >= 27 && $User->getAge() <= 35;
}
function inGroupA(User $User) {
// по хорошему, нужно использовать хэширующую функцию, вроде md5, но для краткости
// сэмулируем 50% пользователем чётными и нечётными id
return self::inTargetGroup($User) && $User->getId()%2 == 0;
}
function inGroupB(User $User) {
return self::inTargetGroup($User) && $User->getId()%2 == 1;
}
function onPageView(User $User, Page $Page, Session $Session) {
if (self::inGroupA($User)) {
// считаем просмотры в memcached
$count = Memcached::getInstance()->increment('Msk25to37GroupAPageViews_'.$Session->getId());
if($count == 5)
new Event('GroupMsk27to35TargetView', $User, $Page);
}
}
}
class PopupMsk27to35 {
function onGroupMsk27to35TargetView() {
if(ExperimentMsk27to35::isOn()) {
new Request('ShowPopupMsk27to35', $Page);
}
}
}
class PopupMsk27to35View extends ViewElement {
protected $render = false;
function requestShowPopupMsk27to35() {
$this->render - true;
}
function onPageRender(Page $Page) {
if($this->render) {
$this->renderTo($Page, 'PopupMsk27to35.tpl', 'PopupMsk27to35.css', 'PopupMsk27to35.js');
}
}
}
class StatMsk25to35 extends Stat {
function onSessionStart(User $User, Session $Session) {
if(ExperimentMsk27to35::inTargetGroup($User)) {
Memcached::getInstance()->set('Msk25to37sessionStartTime_'.$Session->getId(), time());
}
}
function onPageView(User $User, Page $Page, Session $Session) {
if (ExperimentMsk27to35::inTargetGroup($User)) {
// считаем просмотры в memcached
Memcached::getInstance()->increment('Msk25to37PageViews'.$Session->getId());
}
}
function getSuffix(User $User) {
if(ExperimentMsk27to35::inGroupA($User)) {
return "a";
}
if(ExperimentMsk27to35::inGroupB($User)) {
return "b";
}
return $stat_suffix;
}
function onSessionEnd(User $User, Session $Session) {
if(ExperimentMsk27to35::inTargetGroup($User)) {
$time0 = Memcached::getInstance()->get('Msk25to37sessionStartTime_'.$Session->getId());
$sessoin_time = time() - $time0;
$page_views = Memcached::getInstance()->get('Msk25to37PageViews'.$Session->getId());
$stat_suffix = $this->getStatSuffix($User);
$this->writeUserStat($User, 'session_time_'. $stat_suffix, $session_time);
$this->writeUserStat($User, 'page_views_'. $stat_suffix, page_views);
}
}
function onIncomingMoney($User, $MoneyOperation) {
if(ExperimentMsk27to35::inTargetGroup($User)) {
$stat_suffix = $this->getStatSuffix($User);
$this->writeUserStat($User, 'money_'. $stat_suffix, $MoneyOperation->getAmount());
}
}
}
В реальном проекте код выглядит немного по-другому (поддерживается больше возможностей), я намеренно упростил код для демонстрации принципа. К сожалению, реальный код и дизайн привести не могу — NDA.
(offtop: предвосхищая дискуссию на тему «что тут можно улучшить?» — кое-что можно, например, запись статистики сделать отложенной, через очередь, чтобы не плодить запросов к мемкэшу в web-запросе)
Как видим, код простейший и полностью соответствует формальному описанию задачи. Этот код легко понимать и поддерживать. В классах реализованы действия строго на том уровне абстракции, на котором производится описания задачи (обратите внимание на класс PopupMsk27to35, который описывает только поведение, и PopupMsk27to35, который описывает только логику уровня VIew).
Все файлы лежат в одной папке и при удалении этой папки рефакторинг не требуется — просто функциональность исчезает из проекта.
Вопросы и ответы
Вопрос: то есть, зависимости между компонентами заданы неявно? Где-то внутри происходит вызов событий и никак не проследить на какой эвент что выполняется?
Ответ: Ничего подобного. Дело в том, что код связок генерируется статически, и можно зайти внутрь вызова, посмотреть что и где вызывается. Код даже подхватится IDE-шкой и будет работать всё — автокомплит, подсветка синтаксиса. Внешне всё выглядит так, как будто код связок Event-ов/Request-ов и handler-ов писал программист, но на практике программисту не нужно его поддерживать.
Вопрос: а непонятно, в чём отличие Event-ов и Request-ов? Выглядят абсолютно одинаково.
Ответ: различие коренное:
— Event (событие) — это то, что уже произошло. Событие можно записать в очередь и обработать отложено. Request — это то, что нужно сделать перед продолжением вычислений.
— Event не возврашает результата, Request может вернуть результат (и вызывающая сторона ожидает этот результат)
— У Request-а может быть несколько handler-ов, но сработает только один из них. Если же ни одного handler-а не будет (или ни один не сработает), выбросится исключение.
Как отличать реквесты от эвентов на практике? Если какое-то действие не попадает в логике разделения обязанностей (в нашем примере, логика условий показа попапа не должна совпадать с логикой View попапа), мы используем Request для разделения обязанностей. Простой вызов метода сгенерирует нам связанность. Тогда как используя реквесты мы можем показать разные попапы для десктопных и мобильных клиентов, совершенно не касаясь логики условий показа. Каждая логика — на своём уровне абстракции.
Если мы хотим оповестить, что произошло некоторое событие, и нам нужно, чтобы это событие получили несколько слушателей, или нам всё равно, даже если не получит ни один, мы используем Event-ы
Вопрос: не положит ли левый подписчик всё приложение? Когда всё разбито на компоненты, и компоненты — «чёрные ящики», велика вероятность падения из-за говнокода.
Ответ: чисто теоретически, да, можно вызвать фатальную ошибку (если говорить про php). На практике, каждый вызов оборачивается в try/catch, по каждому подписчику автоматически собирается информация о скорости выполнения, и вообще всё под контролем, случайно положить проект не так-то просто. Плюс, юнит-тесты. Кстати, могу порекомендовать попробовать написать юнит-тесты на код выше. Это действительно очень просто.
Плюс, обработку еветнов для статистики, например, можно запихнуть в очередь одной строчкой в конфиге. И всё — среда исполнения изолирована. Это же является плюсом для масштабируемости (автоматически из эвентов получаем очереди).
Вопрос: а если важен порядок исполнения обработчиков? Этот способ уже не подойдёт?
Ответ: конечно же, важен! В реализации есть возможность управления порядком на основе приоритетов (весов) или прямым указанием after/before, как для эвентов, так и для реквестов.
Вопрос: а где пощупать вживую?
Ответ: имеющийся код сейчас закрыт, я работаю над OpenSource-реализацией фреймворка для php и js по данным идеям. Отпишитесь в комментариях, если есть интерес, и я чётче спланирую время, когда смогу открыть код фреймворка для всеобщего доступа.
Автор: okneigres