Code Style Backend

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

Общие положения

В основе положены стандарты PSR-1 и PSR-2, однако есть небольшие поправки и дополнения, а именно:

  1. PHP-код ОБЯЗАТЕЛЬНО следует начинать с полной версии тега <?php и никакие другие (короткие) теги не приемлемы
  2. имена классов, интерфейсов и трейтов, пишутся в стиле UpperCamelCase (PascalCase)
  3. имена свойства класса и обычных переменных, СЛЕДУЕТ объявлять используя camelCase
  4. имена параметров, передаваемые в HTTP запросах, СЛЕДУЕТ писать в snake_case
  5. вместо значения из б.д. используйте константу описывающую значение (это помогает читать код)
  6. все комментарии в коде должны быть на русском языке (облегчается поддержка кода)
  7. сравнение значений должно быть строгим, т.е. с учетом типа: === и !==
  8. функции должны возвращать значение только одного типа, иначе - функция должна выбрасывать Exception
  9. при написании кода избегайте Arrow Anti-Pattern наглядная реализация циклов и множественных условий ( граничный оператор )

API

В нашей команде мы придерживаемся спецификации OpenApi 3.0.2. Быстро войти в курс дел помогут примеры с успешными запросами (можно поиграться).
О том, в какой форме возвращать ответы написано тут
Часто возникает вопрос о том, как описывается результат с ошибкой, поэтому наглядный пример:

    Error:
      type: object
      properties:
        code:
          type: string
        message:
          type: string
      required:
        - code
        - message

Соответственно для поля code нужно указывать одно из значений \Gs\Core\Lib\Helper\Http\ResponseStatus
p.s. если будем описывать свои запросы/ответы с помощью https://github.com/zircote/swagger-php то бонусом получим автоматическую документацию.

Сервис

Мы придерживаемся SOA, а это накладывает требование: работать с объектами, как с сервисами (читайте любой объект класса === сервис). Отличие сервиса от обычного объекта в том, что сервис приобретает состояние во время создания (инициализации через метод __construct) и более не меняет свое состояние (если не брать во внимание изменение данных в зависимостях сервивиса, то можно считать его идемпотентным ). Сервисы лежат в сервис-контейнере (далее контейнер), а чтобы сервис попал в контейнер, сервис нужно зарегистрировать в контейнере. Регистрация в контейнере делается с помощью класса::метода DependencyInjectionProvider::initServices После того, как сервис зарегистрирован, с ним можно работать используется вызов \Container::get($serviceName). Приведем пример:

У нас есть класс \Gs\Core\Lib\Helper\Encoding\EncodingHelper этот класс в рамках SOA является сервисом.

Если мы откроем \Gs\Core\Configuration\DependencyInjectionProvider::initServices то увидим там регистрацию данного сервиса в контейнере:

\Container::make('Gs\Core\Lib\Helper\Encoding\EncodingHelper');

Теперь, если открыть \Gs\Gemopay\Service\Payment\PaymentService::setPaymentOriginatorName то мы увидим, что обратиться к данному сервису (объекту) можно с помощью метода:

\Container::get('Gs\Core\Lib\Helper\Encoding\EncodingHelper')

Важно: сервис это всегда объект, у него не может быть статичных методов.

Данные

Работу с данными мы производим двумя способами:

  • DSP (Data Source Provider) - используется, когда нужно сделать агрегацию или калькуляцию данных в бд, а так же при работе с удаленными данными или с файлами
  • Модель данных (обычно это доменная модель) - используется во всех остальных случаях (т.е. этот способ приоритетней

Модель

Мы избегаем понятия толстая модель, как и избегаем понятия тонкая модель таким образом наши принципы нашей доменной модели:

  • модель должна быть максимально чистой (лишенной бизнес-логики)
  • скоупы желательно делать минимальными (условия описываются применительно только к таблице модели, др. таблицы не используются)
  • условия (conditions) мы выносим в отдельный класс (пример: PatientModel extends PatientModelCondition extends CActiveRecord)

Ниже приводится пример PatientModel и PatientModelCondition + даны комментарии.

<?php

namespace Gs\Gemopay\Entity\Payment;

/**
 * @property integer $id
 * @property integer $order_num
 * @property string $date
 * @property float $total
 * @property OrderModel $order
 */
class PaymentModel extends PaymentModelCondition
{
    // обязательный метод, используется для того, чтобы понять, с какой таблицей бд будет выполняться взаимодействия
    public function tableName()
    {
        return TableNameEnum::PAYMENT;
    }

    // обязательный метод, используется для простого поиска модели (использование статичного вызова разрешено в качестве исключения)
    public static function model($className = __CLASS__)
    {
        return parent::model($className);
    }

    // необязательный метод для задания правил валидации полей при определенных действиях (update, insert)
    public function rules()
    {
        return [
            [
                PaymentFieldEnum::DATE_UPDATED,// это поле может быть с именем 'updated_at' или похожим
                'default',
                'value' => new \CDbExpression('GETDATE()'),
                'setOnEmpty' => false,
                'on' => 'update'
            ],
            [
                PaymentFieldEnum::DATE_INSERTED . ',' . PaymentFieldEnum::DATE_UPDATED,
                'default',
                'value' => new \CDbExpression('GETDATE()'),
                'setOnEmpty' => false,
                'on' => 'insert'
            ]
        ];
    }

    /**
     * Изменение значения полей предпочтительно осуществлять с помощью set-ров,
     * это позволяет исключить неявную работу с данными объекта (инкапсуляция данных), 
     * в подарок мы получаем fluent interface - https://ru.wikipedia.org/wiki/Fluent_interface#PHP, пример: 
     * 
     * @return $this
     */
    public function setName(string $value): self// set-метод в PHP-7 позволяет валидировать данные в момент изменения
    {
        $this->name = $value;
        return $this;
    }
}

/**
 * Этот класс содержит функции добавляющие условия поиска
 * 
 * Документация: https://www.yiiframework.com/doc/guide/1.1/ru/database.ar#sec-12)
 *
 * Функции в этом классе должны быть назван согласно одному из правил:
 * - by[имя поля или бизнес-описание в CamelCase]
 * - where[бизнес-описание в CamelCase]
 *
 * Примеры использования:
 * - $payments = PaymentModel::model()->byOrderNumber(123)->find(); 
 * - $payments = PaymentModel::model()->wherePatientIsMain()->find(); 
**/
class PaymentModelCondition extends \Gs\Core\Lib\Yii\Component\Model\CActiveRecord
{
    // может использоваться в цепочке условий: 

    /**
     * Условие: поиск по номеру заказа
     *
     * @return $this
     */
    public function byOrderNumber($orderNumber)
    {
        $orderNumber = $this->getDbConnection()->quoteValue($orderNumber);
        $this->getDbCriteria()->mergeWith([
            'condition' => PaymentModelFieldEnum::ORDER_NUMBER . ' = ' . $orderNumber,
        ]);
        return $this;
    }

    /**
     * Условие: пациент должен быть основным (или не иметь подтверждения связи с каким-либо основным пациентом)
     *
     * @return $this
     */
    public function wherePatientIsMain()
    {
        $this->getDbCriteria()->addCondition('(' .
            $this->getTableAlias() . '.' . PatientFieldEnum::PARENT_ID . ' = '. ParentIdValueEnum::NOT_SPECIFIED . ' OR ' .
            $this->getTableAlias() . '.' . PatientFieldEnum::PARENT_ID_CONFIRMED_FLAG . ' = '. ParentIdConfirmedFlagEnum::NOT_CONFIRMED .
            ')');
        return $this;
    }

    // необязательный метод для связи текущей модели с моделью SecondModelName
    public function relations()
    {
        return array(
            'some_relation_name' => array(self::BELONGS_TO, 'SecondModelName', ['field_name_of_second_model' => 'field_name_of_this_model']),
        );
    }

    // правила описанные тут, должны касаться только таблицы данной модели (никаких условий выборки др. моделей или джоина др. таблиц мы тут не описываем)
    public function scopes()
    {
        return array(
            'published'=>array(
                'condition'=>'status=1',
            ),
            'recently'=>array(
                'order'=>'create_time DESC',
                'limit'=>5,
            ),
        );
    }
}

Dsp

Теперь рассмотрим создание методов в Dsp (Data Source Provider).

<?php

// для начала пример плохого кода (SQL-запросы частично повторяют друг друга):

class UserDsp implements DataSourceProviderInterface
{
    //...
    /**
     * Найти емэйлы авторизованных пользователей с заданным именем
     * 
     * @param string $name
     * @return \CDbDataReader
     */
    public function getAuthorizedUsers($name)
    {
        return $this->db->createCommand('SELECT email FROM users WHERE flag_deleted = 0 AND username = :name GROUP BY email')
            ->bindValues([':name' => $name])->query();
    }

    /**
     * Найти емэйлы  пользователей с заданным статусом
     * 
     * @param string $userStatus
     * @return \CDbDataReader
     */
    public function getUsersByStatus($userStatus)
    {
        return $this->db->createCommand('SELECT email FROM users WHERE flag_deleted = 0 AND user_status = :user_status GROUP BY email')
            ->bindValues([':user_status' => $userStatus])->query();
    }
}

// Часть SQL-запроса функции getUsersByStatus повторяет часть SQL-запрос функции
// `getAuthorizedUsers`, а значит нужно вынести данный SQL-запрос в отдельный метод
// таким образом, должен получиться приватный метод вида getUsers(array $select, array $where):

class UserDsp implements DataSourceProviderInterface
{
    // ...
    /**
     * Найти ID-ки авторизованных пользователей с заданным именем
     * 
     * @param string $name
     * @return \CDbDataReader
     */
    public function getAuthorizedUsers($name)
    {
        return $this->getUser(['email'],['username' => $name]);
    }

    /**
     * Найти данные пользователей с заданным статусом
     * 
     * @param string $userStatus
     * @return \CDbDataReader
     */
    public function getUsersByStatus($userStatus)
    {
        return $this->getUser(['email'],['user_status' => $userStatus]);
    }

    /**
     * @param array $select
     * @param array $where
     * @return \CDbDataReader
     */
    private function getUsers(array $select, array $where = [])
    {
        $sql = "SELECT " . implode(",", $select) . " FROM users WHERE flag_deleted = 0 GROUP BY email";
        $bindValues = [];
        if (count($where) > 0) {
            foreach ($where as $fieldName => $fieldValue) {
                $sql .= " AND " . $fieldName . " = :" . $fieldName;
                $bindValues[":" . $fieldName] = $fieldValue;
            }
        }
        return $this->db->createCommand($sql)->bindValues($bindValues)->query();
    }
}

Таким образом, выше представлена не калька, а пример демонстрирующий подход, при котором SQL-запросы не повторяются в Dsp.

Полезные статьи

  1. ТТУК - толстые, тупые, уродливые контроллеры
  2. Изучаем YAML

12.12.2010 12:57