Новая жизнь legacy проекта

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

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

    Дано


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

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

    Создание моделей


    Данные это самое главное, что у вас есть, особенно если вы хотите “выкинуть” движок. Поэтому начинать разработку нового движка логично с того, чтобы подцепить данные из текущей базы. В случае с symfony мы будем создавать модели. На самом деле здесь все довольно просто, базовые поля вы можете создать с помощью команды doctrine:mapping:import (подробнее можно почитать здесь). Для того, чтобы не обрабатывать сразу все таблицы, команду запускали на урезаной базе, где остались только основные таблицы (заказы, билеты, мероприятия и пользователи, плюс пара таблиц связей и расширенных данных)

    Здесь вы можете встретить первые проблемы. В нашей базе оказались enum, которые доктрина никак не хочет переваривать, но за пару часов работы получилось составить миграцию (здесь имеется ввиду просто sql файл, а не миграция доктрины) на наш небольшой десяток этих полей, которая заменила enum на строки/числа.

    Немного поработав с колонками мы без проблем получи и связи в наших объектах. Если конечно старые разработчики заботились о вас и использовали primary key в качестве связи или какие-то колонки, с помощью которых вы бы могли описать связи one/many. Так что здесь нам немного повезло.

    Почти повезло. Вторая проблема оказалась с заказами, слово order является зарезервированными, в одной из таблиц, есть колонка которая содержит в этом поле id заказа (спасибо автору проекта, что в других таблицах он неожиданно стал использовать ord_id а не слово order). Здесь из быстрых решений оказался только хак с загрузкой.

    public function postLoad(LifecycleEventArgs $event)
    {
        $entity = $event->getEntity();
    
        if ($entity instanceof Ticket) {
            // getOrderNumber выдает номер который содержится в колонке order
            $id = $entity->getOrderNumber();
    
            if ($id) {
                $order = $event->getEntityManager()->getRepository('AppBundle:Orders')->find($id);
    
                if (!$order) {
                    throw new \Exception(sprintf("Order %d not found in base", $id));
                }
    
                // setOrder/getOrder на самом деле работают со скрытым полем orderObject который содержит в себе объект заказа
                $entity->setOrder($order);
            }
        }
    }
    


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

         /**
          * Модель заказа
          * @ORM\Table(name="`orders`")
          */
    
         /**
          * Привязка к заказу
          * @ORM\ManyToOne(targetEntity="Orders")
          * @ORM\JoinColumn(name="order", referencedColumnName="id", nullable=true)
          */
         private $order;
    
        //доктриновский листенер который перехватывает работу с полем заказа в базе
        public function preUpdate(PreUpdateEventArgs $args)
        {
            $entity = $args->getEntity();
    
            if ($entity instanceof Ticket) {
                if ($changes = $args->getEntityChangeSet()) {
                    if (isset($changes['order'])) {
                        $em = $args->getEntityManager();
                        $uow = $em->getUnitOfWork();
                        $metadata = $em->getClassMetadata(Ticket::class);
    
                        //Пишем в доктрину, что колонка изначально была с новым значением
                        $data = $uow->getOriginalEntityData($entity);
                        $data['order'] = $changes['order'][1];
                        $uow->setOriginalEntityData($entity, $data);
    
                        //Выполняем запрос, чтобы обновить колонку order
                        $query = sprintf(
                            'UPDATE `%s` SET `order` = %s  WHERE id = %d',
                            $metadata->table['name'],
                            $changes['order'][1] instanceof Orders ? $changes['order'][1]->getId() : 'null',
                            $entity->getId()
                        );
                        $em->getConnection()->executeQuery($query);
    
                        //Заставляем доктрину забыть про то, что были изменения и заставляем пересчитать их
                        //Первое нужно, так как если менялся только заказ, он не обнулит изменения
                        $uow->clearEntityChangeSet(spl_object_hash($entity));
                        $uow->recomputeSingleEntityChangeSet($metadata, $entity);
                    }
                }
            }
        }
    

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

    /**
     * @ORM\Column(type="integer", options={"default" = 0})
     */
    public $type = 0;
    

    Замена крона


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

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

    Первые шаги (отдельный виджет)


    Второй блок который был реализован на новом движке, это неожиданно подвернувшийся виджет для партнеров. Из себя он представлял небольшую страницу, которая через iframe встраивается на партнерский сайт. А также эта страница требовала отдельного управления в админке. Честно говоря, когда я читал требования и осознавал, что новый движок только что прошел боевое крещение, я думал, что идеальнее просто быть и не может.

    С самим iframe проблем нет, верстка отдельно, шаблон отдельно, даже отдельная таблица, для управления тем, что нужно показывать. Но это все требовалось редактировать через админку, и самое главное, чтобы пользователь не почувствовал разницы (или почувствовал только в лучшую сторону). Поэтому надо было решить две проблемы: авторизация и внешний вид форм.

    Первое решилось через механизм guard’ов. Понадобился только перенести логику из старого кода в методы getCredentials и getUser. Для нашего проекта вся логика заняла меньше 10 строк. За мои 5 лет на симфонии, у меня никогда не было более быстрой настройки авторизации и всего лишь одним классом. Вопрос с внешним видом легко решился с помощью стилизации форм.

    Настройка nginx


    Теперь надо запустить все это чудо. На старом проекте использовалась связка nginx+apache, и здесь есть некоторые проблемы, что конфиги апача мало кто толком сейчас умеет настраивать с разделениям по путям, поэтому был организован второй бэкэнд с php-fpm. Новые пути отправлялись на app.php а он уже в свою очередь отправлялся на fpm с корнем в папке нового проекта. Собственно в конфиг был добавлен примерно такой код.

    location ~ ^/admin/parnter {
        root  /path-to-new-engine/web;
        try_files $uri /app.php$is_args$args;
    }
    
    location ~ ^/app\.php(/|$) {
        root  /path-to-new-engine/web;
        Internal;
        ...fast_cgi config...
    }
    

    Что в итоге?


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

    Похожие публикации

    Комментарии 22

      +3
      Что то очен легко обошлось :)
        0
        «Дело мастера боится» — русская народная поговорка.

        Заказчику повезло с Team Lead-ом.
        Им найден способ как практически бескровно начать «апгрейдить» текущее ИТ-решение.

        Правда, этот подвиг скорей всего останется не замеченным Заказчиком, но я вот для себя добавил Александра в «избранных» хабрачан, если мне или кому-то из моих друзей понадобится Мастер.
          0
          Приятно слышать такую оценку)
          Да, может заказчик никогда и не узнает, но он где-то далеко. Я больше стараюсь думать о людях которые вокруг меня и совершать маленькие подвиги для них)
          0
          Просто замену прошло не больше 2% фич проекта, я надеюсь я когда-нибудь напишу материал, какой кровью обошлась полная замена таким методом)
            0
            Сколько вас ждет усилий и времени я могу представить, у нас самих ушло несколько лет на то, чтобы переработать ИТ-решение. Дело ли мы это совмещая «апгрейд» старых фич и создавая новые, без открытия специальных проектов рефакторинга под это.

            Тоже написал статью про наш «legacy» проект на днях.
            Так как был руководителем проектов, то начали раскручивать клубок через технические задания. Приводя в порядок их и те куски, что им соответствовали внутри.

            В нашем случае Проблема выглядела так, когда мы делали что-то одно, у нас отваливалось в нескольких других местах. У Заказчика сложилось впечатление что «Нормально» сделать с первого раза мы ничего не можем. Причина её заключалась «в сплошном коде», в котором было непонятно где заканчивается одна фича и начинается новая, и задача была разметить его на «функциональные области».

            UPD. «Сплошной код» это была не вина Разработчиков, так как система изначально была коробочной и для настройки, а не для того чтобы на её основе разрабатывать отраслевое ИТ-решение.

            Прошу простить мой технический русский, не разработчик, это мой обывательский взгляд РП-БА на ИТ-систему.
          • НЛО прилетело и опубликовало эту надпись здесь
              +1
              представьте 100K строк кода в старой версии контур-экстрена на сишарп с первым билдом в 1998

              Не могу себе представить C# в 1998 году. Посмотрел историю вопроса на википедии: Проект C# был начат в декабре 1998 и получил кодовое название COOL (C-style Object Oriented Language). Версия 1.0 была анонсирована вместе с платформой .NET в июне 2000 года, тогда же появилась и первая общедоступная бета-версия; C# 1.0 окончательно вышел вместе с Microsoft Visual Studio .NET в феврале 2002 года.
              • НЛО прилетело и опубликовало эту надпись здесь
                • НЛО прилетело и опубликовало эту надпись здесь
            +1
            С enum, можно было проще поступить. Нужно было просто добавить свой тип.
            С order проблема тоже решается просто:
            /**
             * @ORM\Column(name="`order`", type="integer")
             */
            private $order;
            

            Просто обрамляем апострофами
              0

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


              Про заказы, да, ваш способ работает, если вам нужен номер заказа, а нам нужен объект заказа, и вот такой способ не работает:


                  /**
                   * @ORM\ManyToOne(targetEntity="Orders")
                   * @ORM\JoinColumn(name="`order`", referencedColumnName="id")
                   */
                  private $order;
                0
                Хм, странно, только что попробовал, все нормально работает. И механизм миграций отработал как надо, и запросы, которые через QueryBuilder, не развались.
                  0
                  /**
                   * @ORM\Entity()
                   * @ORM\Table(name="`order`")
                   */
                  


                  Возможно, Ваша проблема решилась апострофами в имени таблицы, если дело не в поле
                    0

                    Вот этот вариант сработал, только проблема, что данные в базу попадают, а когда выбираешь, поле order = null. Надо смотреть, что происходит в гидраторе. А у вас какая версия симфонии и доктрины (orm и dbal)?

                      0

                      Вообщем дебаг показал, что на 2.4.8 (это наша текущая версия), такой вариант не подходит, дело в этой строчке: https://github.com/doctrine/doctrine2/blob/v2.4.8/lib/Doctrine/ORM/UnitOfWork.php#L2624


                      Он показывает такие значения и не грузит объект


                      $data[$srcColumn] = null
                      $srcColumn = "order"
                      $data['order'] = null
                      $data['`order`'] = 3442
                        +2
                        Версия доктрины у меня точно такая же, как и у Вас. Чтобы все работало, вы должны оставить апострофы в
                         * @ORM\Table(name="`order`")
                        

                        и убрать их в
                             * @ORM\JoinColumn(name="order", referencedColumnName="id")
                        

                        На офф сайте доктрины можно узнать, что по умолчанию экранирование полей используемых для джоинов игнорируется. Поэтому у вас поле `order` интерпритируется неверно (с апострофами). Прежде чем написать, я проверил это локально.
                          0

                          Да, так проблем почти нету, объекты попадают в базу, вытягиваются оттуда, но к сожалению связь не удаляется.


                          $entity->setOrder(null);

                          Приводит к ошибке: SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'order = NULL WHERE id = 41771' at line 1

                            0
                            Да, у Вас похоже без хаков не обойтись. Обычно сталкиваюсь с проблемой когда либо обычное поле именовано зарезерверованным словом, либо имя таблицы.

                            Было бы интересно узнать, если кто-то разрулил и такой кейс более изящно
                              0

                              image


                              Я обновил блок про решение проблемы, мы через пару дней нашли способ оставив связь обойти эту ошибку. Мы погрузились в недры доктрины, и реализовали новый хак, который работает на более глубоком уровне и чуть более изящный, чем первый вариант на уровне объекта. Спасибо за ваши комментарии, они нам реально помогли.

                  +1
                  > класс 2.000+ строк
                  Это не много. Это вполне себе такая средняя (толстая) entity в какой-нибудь ММО. Типа игрока или торговца.
                  А в банковском деле это даже не java-транзакция.
                    +1

                    Всё зависит от того, что это за 2000+ строк.


                    Если это нормально побитый на методы класс + куча каких-нибудь геттеров/сеттеров, то и ладно (хоть и многовато на мой вкус, но читабельно).


                    Но реальность такова, что чаще всего в легаси-проектах в этих 2000+ строках полтора метода, которые занимаются всем и дёргают всё возможное (и SQL через heredoc, и строковые подстановки вместо шаблонов, и глобальные переменные/синглтоны/реестры, и т.д). Такое разбирать и поддерживать действительно сложно.

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

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое