Идеальный код, не мешай нам жить!

Почему идеальный код мешает нам жить? Сейчас поясню на примере. Возьмём для удобства реальную задачу. Мне предложили за деньги встроить в проект на Zend Framework 1 миграции для базы данных. Чтобы разработчики могли в 100 параллельных проектах изменять 100 баз под насущные нужды.

Задача проста, задача ясна. Но как вы думаете, какой был первый вопрос ко мне по реализации? Какие шаблоны программирования я буду использовать? Или может быть, какие я спроектирую классы? Или как я назову базовый класс миграций? Вовсе нет. Вопрос был только один - сколько это будет стоить. Ещё нет ни намёка на реализацию, а с меня уже трясут конкретные цифры. А правда, сколько это может стоить? Давайте будем посмотреть.

Миграции

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

Следуя принципу единой отвественности, мы должны разнести логику таким образом, чтобы каждый участник процесса отвечал только за свою логику, и никак иначе. А следуя принципам TDD, мы должны сначала написать тест, а потом только код. Предположим, что для идеальности кода, этих принципов вполне хватит. И добавим сюда ещё принцип слабосолёности слабосвязанности кода, когда во все места следует сувать интерфейсы вместо классов. Можно добавить сюда ещё много интересных принципов, но пока хватит.

Рисуем участников процесса

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

Диаграмма участников процесса
Рис. 1 — Диаграмма участников процесса

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

Теперь можно рисовать UML-диаграмму классов, которые будут в реализации. Нарисуем контроллер, саму миграцию и интерфейс базы данных.

UML-диаграмма классов
Рис. 2 — UML-диаграмма классов

Я помню про принцип единой ответственности, поэтому у каждого класса должна быть только одна причина для изменения. Это значит, что в схему надо добавить пару репозиториев и фабрику. Иначе контроллер будет слишком много на себя брать.

Полная UML-диаграмма классов
Рис. 3 — Полная UML-даиграмма классов

В данной схеме меня сильно смущает MigrationController, который никак не вписывается в принцип единой отвественности. Но предположим, что это фасад, и не будем сильно заморачиваться.

Рисовашки закончены и можно приступать к коду.

Утром тесты, вечером код

Прежде чем бросаться писать реализацию по UML-диаграммам, неплохо бы написать пару тестов. Так нам велит великое TDD. А с ним шутки шутить нельзя!

Для идеального кода нам понадобятся идеальные тесты. Это аксиома, которую не нужно доказывать. А как писать идеальоные тесты? По слухам, для каждого класса и даже метода нужен свой UNIT-тест. Тогда не останется места глупым ошибкам и очень удобно будет менять код в будущем.

Начнём с самой важной части проекта - миграции:

class MigrationTest
{
    private $database, $factory;

    private function init()
    {
        $this->database = new Database();
        $this->factory  = new Migration\Factory();
    }

    function testMigration()
    {
        $migration = $this->factory->makeNew();
        
        $migration->setApplyCallback(function(DatabaseInterface $db) {
            $db->createTable('test')
                ->addColumn($db->columnSerialPrimaryKey('id'))
                ->addColumn($db->columnString('name', 255))
                ->addColumn($db->columnText('comment'))
                ->execute()
            ;
            
            $db->insertInto('test')
                ->values($db->defaultValue(), 'test', 'comment')
                ->execute()
            ;
        });
        
        $migration->apply($this->database);
        $values = $this->database->select('*')->from('test')->fetchAllRows();
        $this->assertEqual([[1, 'test', 'comment']], $values);
    }
}

Ну что же, на мой взгляд, выглядит неплохо. Правда, неожиданно появился построитель запросов. Я его не планировал использовать в проекте, но идеальные тесты требуют идеального ООП. А могу ли я написать построитель запросов? Конечно могу! Хотя это и не входило в изначальну задачу, но наверняка этот код пригодится где-то ещё. Я видел, что все крупные проекты используют построители запросов, значит и мне нужно идти в ногу с ними! А может взять готовое решение? Да, пожалуй, стоит задуматься о готовом решении. Это сократит и время, и ошибки.

И вот я уже не думаю о миграциях, а ищу готовые построители запросов. Смотрю, как это работает в Yii, Simphony, Laravel. Через два часа штудирования мануалов вдруг приходит гениальная идея: "А почему бы просто не взять готовое решение с миграциями? Это же проще, быстрее и ничего изобретать не надо!" Идеально.

Есть, правда, одно но - в изначальном задании что-то говорилось про зенд первый и сто проектов, но это мелочи по сравнению с построителем запросов. Что-нибудь придумаем...

Как было всё на самом деле

Мне действительно дали эту задачу с миграциями. Действительно проекты были на Zend Framework 1. На её выполнение я потратил ровно два часа. Я не рисовал ни схем, ни UML-диаграмм. Просто сел и начал писать код. Без ООП, без тестов, без продумывания архитектуры приложения. На это ушло ровно два часа, как я и планировал. Ещё хватило времени написать документацию и пример использования.

Получилась следующая реализация:

  1. Файл new.php - создаёт новую миграцию.
  2. Файл apply.php - применяет все миграции, которых нет в базе данных.
  3. Файл list.php - выводит список миграций, которые есть в базе, и новые.
  4. Файл lib.inc.php - функции для работы скриптов.

Вот так выглядит файл apply.php:

#!/usr/bin/env php
<?php
namespace migrations;

require_once __DIR__ .'/lib.inc.php';

ini_set('display_errors', true);
install_db();

foreach (migrations_all() as $id => $item) {
    if (empty($item['applied'])) {
        printf("Apply migration %s\n", $id);
        __include($item['file']);
        save_migration($id);
    }
}

function __include($file) {
    return include $file;
}

А это сама миграция:

<?php
namespace migrations;

require_once __DIR__ .'/lib.inc.php';

// Write your code here
//
// You can use
// db_query('some sql');  for quering
// db_pdo()->...;         for pdo functions

db_query('
CREATE TABLE IF NOT EXISTS `_test` (
    `test` text
) DEFAULT CHARSET=utf8
');

$stm = db_pdo()->prepare('INSERT INTO `_test` VALUES (?), (?)');
$stm->execute(['hello', 'world']);

$stm = db_pdo()->query('SELECT * FROM `_test`');

print_r($stm->fetchAll(\PDO::FETCH_COLUMN));

db_query('DROP TABLE `_test`');

Обратите внимание, насколько простой код. В нём не используются сложные конструкции, хитрые объекты, инжекторы зависимостей и прочие радости жизни. Я даже не стал подключать к нему Zend Framework с его уже готовыми решениями для работы с базой. Это решение рассчитано на программистов с любым уровнем знаний.

Вся логика миграций уместилась в четырёх файлах общим размером 200 строк.

Вопросы и ответы

Почему я не использовал объекты? Тут всё просто - простым задачам не нужны сложные решения. Для объектов надо писать тесты, рисовать диаграммы, продумывать архитектуру. Во-первых, на это не было времени. Во-вторых, это не дало бы никаких преимуществ, поскольку область применения ограничивается только миграциями. В-третьих, я не люблю засорять проект классами, которые нельзя в нём использовать.

Почему бы не использовать готовое решение? Я не могу спрогнозировать количество времени, которое потребуется на поиск готового решения. И кто даст гарантию, что оно будет совместимо с текущим проектом? Невозможно угадать, сколько времени уйдёт на ингтеграцию стороннего решения в проект. А работать нужно в рамках бюджета.

Почему нет роллбэков? Потому что их не было в начальном задании. Но если они потребуются, я добавлю ещё один файл rollback.php размером 10-20 строк, и они появятся. Займёт это примерно 10-20 минут, включая документацию.

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

Мне общали идеальный код!!! Давайте посчитаем. Я потратил час на продумывание архитектуры приложения, час на диаграммы, полчаса на тест. А чтобы этот тест заработал, мне нужно было бы реализовать построитель запросов. Это новые схемы, диаграммы, тесты. Примерное время реализации - неделя. При этом никаких гарантий, что это будет хоть чуточку полезный код.

Сколько это стоит? Реализация на функциях стоила мне два часа рабочего времени. Для клиента это стоило $30 (два часа на $15 в час). Если бы я начал заморачиваться с идеальным ООП, то стоило бы это 40 часов на $15 = $600. Не думаю, что заказчик готов был платить больше $40, поэтому мои потери от идеального кода составили бы $560. А если учесть, что заказчик так и не заплатил, то в данной ситуации идеальным был бы любой код, даже нерабочий.

Нужно ли идеальное ООП? И если да, то где? Идеальное ООП нужно использовать в больших проектах, где работают десятки, а то и сотни программистов. В таких проектах любая ошибка черевата убытками. Поэтому надо исключать ошибки ещё до написания кода. Для маленьких проектов идеальным будет код, который можно легко и быстро менять. Ошибки тут не так важны.

Постскриптум

В этой статье я хотел показать, как идеальный код может стать ловушкой для программистов, которые сразу хотят делать всё "идеально". Как передовые технологии, которые помогают одним, могут мешать другим. Как неверная оценка задачи приводит к потере рабочего времени.

Если вам интересно программирование, попробуйте решить эту задачу самостоятельно. И отслеживайте, сколько времени вы тратите на каждый этап. Ориентируйтесь на следующее критерии выполнения:

  1. Есть возможность создать новую миграцию.
  2. Есть возможность применить миграции. При этом они выполняются последовательно и только один раз.
  3. Есть возможность посмотреть список миграций (новых и применённых).
  4. Документация с примреами.

Автор: Антон Прибора. 17 декабря 2017 года.

Хорошая статья, мне понравилась. Оставлю отзыв!