Pull to refresh

Методика проектирования CORE

Reading time12 min
Views13K
Я работаю программистом более 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, который описывает только поведение, и PopupMsk27to35View, который описывает только логику уровня 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 по данным идеям. Отпишитесь в комментариях, если есть интерес, и я чётче спланирую время, когда смогу открыть код фреймворка для всеобщего доступа.
Tags:
Hubs:
+6
Comments4

Articles

Change theme settings