Русский
Русский
English
Статистика
Реклама

Specification

Из песочницы Понимание спецификации ECMAScript, часть 2

02.10.2020 16:05:32 | Автор: admin

Привет, Хабр! Представляю вашему вниманию перевод статьи под редакцией xfides


Автор оригинала: Marja Hltt
Перевод первой части.



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


Готовы для второй части?


Предупреждение! Этот эпизод содержит копию алгоритмов изспецификации ECMAScript отфевраля 2020года. Естественно, современем информация будет устаревать.


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


const o1 = { foo: 99 };const o2 = {};Object.setPrototypeOf(o2, o1);o2.foo;//  99

Где определяется алгоритм хождения попрототипной цепочке?


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


Есть два метода [[GetOwnProperty]] и [[Get]]. Нам интересен тот, который умеет работать спрототипной цепочкой это метод [[Get]]. Ксожалению, дескриптор свойства вспецификации также имеет поле под названием [[Get]], поэтому, читая спецификацию, необходимо различать, когда речь идет одескрипторе или обалгоритме.


[[Get]] это базовый внутренний метод. Категория Обычных объектов реализуют поведение (алгоритм использования объекта) поумолчанию для базовых внутренних методов. Категория Экзотичных объектов могут определять свой собственный внутренний метод [[Get]], логика которого отличается отповедения поумолчанию. Вэтом посте мысфокусируемся наобычных объектах.


Метод обычного объекта [[Get]] ( P, Receiver ). вызывает OrdinaryGet. При этом, когда вызывается внутренний метод [[Get]] отобъекта О сосвойством Р иECMAScript значением Receiver, выполняется следующий шаг:


    1. Return ? OrdinaryGet(O, P, Receiver).

Вскоре мыувидим, что Receiver это значение, которое используется как this значение, когда вызывается геттер функция свойства-акцессора.


OrdinaryGet(O, P, Receiver) определен следующим образом:


1.  Assert: IsPropertyKey(P) is true.2.  Let desc be ? O.[[GetOwnProperty]](P).3.  If desc is undefined, then        a. Let parent be ? O.[[GetPrototypeOf]]().        b. If parent is null, return undefined.        c. Return ? parent.[[Get]](P, Receiver).4.  If IsDataDescriptor(desc) is true, return desc.[[Value]].5.  Assert: IsAccessorDescriptor(desc) is true.6.  Let getter be desc.[[Get]].7.  If getter is undefined, return undefined.8.  Return ? Call(getter, Receiver).

Прототипная цепочка идет внутри третьего шага: если мыненашли собственное свойство, мывызываем прототипный [[Get]] метод, который снова вызывает OrdinaryGet. Если мывсе еще ненашли свойство, мывновь вызываем прототипный [[Get]] метод, который опятьже передает вызов OrdinaryGet. Так продолжается дотех пор, пока мыненайдем свойство, или недостигнем объекта спрототипом равным null.


Давайте посмотрим, как этот алгоритм работает, когда мыобращаемся к o2.foo. Сначала мывызываем OrdinaryGet ипередаем вкачестве параметра О объект о2, авкачестве параметра Р имя свойства foo. После того, как O.[[GetOwnProperty]](foo) возвращает undefined, мыидем вконструкцию ifвшаге3, поскольку объект o2 неимеет собственного свойства под именем foo.


Вшаге 3.a, мывозвращаем впеременную parent ссылку напрототип объекта o2 это объект o1. Так как parent не null, мынепроходим проверку ifнашаге 3.b.


Вшаге 3.с мывызываем родительский метод [[Get]] сназванием свойства foo ивозвращаем его результат. Так как родительский объект o1 обычный объект, тометод [[Get]] вызывает OrdinaryGet снова. Вэтом случае, вкачестве параметра О попадает о1, авР передается foo.


Нашаге 2метод O.[[GetOwnProperty]](foo) возвращает ассоциированный сосвойством дескриптор, имысохраняем его впеременную desc.


Дескриптор свойства это тип спецификации. Дескрипторы данных хранят значения свойства непосредственно вполе [[Value]]. Дескриптор акцессор хранит функции акцессоры вполе [[Get]] и/или [[Set]]. Внашем случае, дескриптор свойства ассоциируемый сfoo это дескриптор данных.


Как выпомните, дескриптор свойства мысохранили в desc нашаге2, поэтому мынепроходим проверку ifнашаге 3.


Далее мывыполняем шаг 4. Дескриптор свойства представлен дескриптором данных, поэтому вернется значение99, которое лежит вполе [[Value]] нашаге 4. Инаэтом закончится алгоритм.


Что такое Receiver иоткуда онвзялся?


Параметр Receiver используется только вслучае свойств-акцессора вшаге 8. Онпередает значение this, когда вызывается геттер функция свойства-акцессора.


OrdinaryGet передает оригинальный Receiver через рекурсию без изменений (шаг 3.c). Давайте выясним, откуда взялся оригинальный Receiver.


Наместе, где вызывается [[Get]], будет абстрактная операция GetValue, которая работает сReference. Reference это тип спецификации, содержащий базовое значение, указанное имя ифлаг strict. Внашем случае с o2.foo базовое значение будет объект o2, указанное имя строка foo, афлаг strict false.


Отступление: почему тип Reference неявляется типом Record?


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


Вернемся кGetValue.


Давайте посмотрим, как алгоритм GetValue ( V ) описан:


1.  ReturnIfAbrupt(V).2.  If Type(V) is not Reference, return V.3.  Let base be GetBase(V).4.  If IsUnresolvableReference(V) is true, throw a ReferenceError exception.5.  If IsPropertyReference(V) is true, then     а.If HasPrimitiveBase(V) is true, then         i.Assert: In this case, base will never be undefined or null.         ii.Set base to ! ToObject(base).     b.Return ? base.[[Get]](GetReferencedName(V),   GetThisValue(V)).6.  Else,      a.Assert: base is an Environment Record.      b.Return ? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))

Reference внашем примере это o2.foo, которое является property reference.


Итак, мызаходим вifнашаге 5. Непроходим проверку нашаге 5.a, поскольку объект о2 неявляется примитивом (число, строка, символ, BigInt, Boolean, Undefined, или Null).


Затем мывызываем [[Get]] нашаге 5.b. Receiver, который мыпередаем это значение, полученное после абстрактной операции GetThisValue(V). Внашем случае GetThisValue( V ) вернет значение базы Reference:


1.  Assert: IsPropertyReference(V) is true.2.  If IsSuperReference(V) is true, then        a.Return the value of the thisValue component of the reference V.3.  Return GetBase(V).

Для нашего примера o2.foo, мынепроходим ifнашаге2, поскольку o2.foo неявляется Super Reference(как например super.foo), однако, выполняем шаг 3ивозвращаем значение базы Reference, которым является объект o2.


Объединив все вместе, мыобнаружим, что мызадаем Receiver быть значением базы для оригинальной Reference, и, таким образом, мысохраняем его неизменным вовремя прохождения попрототипной цепочки. И, витоге, если свойство, которое мынашли, является свойством-акцессором, мыиспользуем Receiver как значение this.


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


Давайте опробуем!


const o1 = { x: 10, get foo() { return this.x; } };const o2 = { x: 50 };Object.setPrototypeOf(o2, o1);o2.foo;//  50

Вэтом примере унас есть свойство-акцсессор под именем foo, имыопределяем геттер для него. Геттер возвращает this.x..


Затем мыпытаемся получить значение o2.foo что вернет геттер?


Мыобнаружили, что, когда мывызываем геттер, значение this является объект, изкоторого мыизначально пытались получить свойство, анеобъект, вкотором мыего нашли. Внашем случае, значением this будет объект о2, анеобъект о1. Вэтом мыможем удостоверится, проверив, что возвращает геттер: o2.x или o1.x. Вдействительности онвозвращает o2.x.


Да, это работает! Мысмогли спрогнозировать поведение небольшого кусочка кода наосновании того, что мыпрочитали вспецификации.


Свойства акцессоры почему они вызывают [[Get]]?


Где вспецификации сказано, что внутренний метод объекта [[Get]] будет вызывается, когда получит доступ ксвойству o2.foo? Конечно, это должно быть где-то описано. Неверьте мне наслово!


Мынашли, что внутренний метод объекта [[Get]] вызвался изабстрактной операции GetValue, которая работает сReferences. Нооткуда вызывается GetValue?


Семантика вовремя выполнения для MemberExpression


Грамматические правила спецификации определяют синтаксис языка. Семантика вовремя выполнения определяет смысл синтаксических конструкции, ито, как они будут выполнятся.


Если выдосих пор незнакомыс контекстно-свободной грамматикой, самое время познакомится снее сейчас.


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


Следующие продукции описывают, как выглядит MemberExpression :


MemberExpression :     PrimaryExpression     MemberExpression [ Expression ]     MemberExpression . IdentifierName     MemberExpression TemplateLiteral     SuperProperty     MetaProperty     new MemberExpression Arguments

Здесь представлены 7продукций для MemberExpression.


MemberExpression может быть просто PrimaryExpression. Также MemberExpression может состоять издругого MemberExpression и Expression, соединенных вместе: MemberExpression[Expression], например o2[foo]. Или онможет быть MemberExpression.IdentifierName, например o2.foo это представление подходит для нашего примера.


Семантика вовремя выполнения для продукции Runtime Semantics: Evaluation for MemberExpression: MemberExpression. IdentifierName имеет следующим алгоритм:


1.  Let baseReference be the result of evaluating MemberExpression.2.  Let baseValue be ? GetValue(baseReference).3.  If the code matched by this MemberExpression is strict mode code, let strict be true; else let strict be false.4.  Return ? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict).

Алгоритм переходит кабстрактной операции EvaluatePropertyAccessWithIdentifierKey, поэтому нам также необходимо еепрочитать. Абстрактная операция EvaluatePropertyAccessWithIdentifierKey(baseValue, identifierName, strict) принимает вкачестве аргументов значение baseValue, identifierName, атакже strict ивыполняет следующий алгоритм:


1.  Assert: identifierName is an IdentifierName2.  Let bv be ? RequireObjectCoercible(baseValue).3.  Let propertyNameString be StringValue of identifierName.4.  Return a value of type Reference whose base value component is bv, whose referenced name component is propertyNameString, and whose strict reference flag is strict.

Таким образом, EvaluatePropertyAccessWithIdentifierKey создает Reference, который использует предоставленный baseValue вкачестве base, строковое значение identifierName как имя свойства, иstrict как флаг строго режима.


Витоге, Reference передается в GetValue. Это прописано внескольких местах вспецификации, взависимости оттого, как Reference вконечном итоге будет использован.


MemberExpression как параметр


Внашем примере мыиспользуем свойство доступа как параметр:


console.log(o2.foo);

Вэтом случае, используется продукция ArgumentList: AssignmentExpression. Дальше рассматривается ееповедение, определенное валгоритме семантики для этой продукции. Внем вызывается GetValue для аргумента:


Runtime Semantics: ArgumentListEvaluation


1.  Let ref be the result of evaluating AssignmentExpression.2.  Let arg be ? GetValue(ref).3.  Return a List whose sole item is arg.

o2.foo невыглядит как AssignmentExpression, ноэто так иесть, поэтому применяем эту продукцию. Узнать, почему так, выможете, прочитав эту дополнительную статью, новэтом нет острой необходимости сейчас.


Вшаге 1происходит вычисления алгоритма AssignmentExpression, которым является o2.foo. Вref попадет результат вычисления.


Вшаге 2мы вызываем GetValue отнего. Таким образом, мызнаем, что внутренний метод объекта [[Get]] будет вызван, иобход попрототипной цепочки произойдет.


Резюме


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

Подробнее..

Перевод Язык моделирования Alloy и приключения с параллельными запросами к базе данных

10.03.2021 16:14:14 | Автор: admin

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



О качестве программного обеспечения и инструментарии


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


  1. Анализ и создание спецификаций
  2. Устранение простых ошибок с использованием системы типов Haskell
  3. Стандартные юнит-тесты и интеграционные тесты
  4. Непрерывная интеграция
  5. Обязательные ревью кода
  6. Тестирование на стендах, проводимое QA инженерами
    (мы используем Octopod для оптимизации процесса разработки и QA)
  7. Тестирование в pre-production среде
  8. Ведение логов и контроль ошибок на этапе эксплуатации

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


Один из способов сокращения этих затрат заключается в выявлении ошибок на ранней стадии. По грубой оценке, если система типов обнаруживает вашу ошибку, это происходит в течение 30 секунд после сохранения файла. Если ошибка найдена во время CI, получение информации об ошибке займёт до 30 минут. Кроме того, после исправления ошибки вам придётся ждать еще 30 минут, пока CI не отработает снова.


Чем дальше вы продвигаетесь по цепочке, тем длиннее становятся эти перерывы и тем больше ресурсов уходит на исправление ошибок: чтобы достигнуть этапа QA-тестирования могут потребоваться дни, после чего инженер-тестировщик еще должен будет заняться вашей задачей. А если на этом этапе будет обнаружена ошибка, то не только тестировщикам придется снова провести тесты после исправления ошибки, но и разработчики опять должны будут пройти все предыдущие стадии!


Итак, каков самый ранний этап, на котором мы можем выявить ошибки? Удивительно, но мы можем существенно повысить шансы на выявление ошибок ещё до того, как будет написана первая строка кода!


Alloy выходит на сцену


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


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


Давайте приведем пример. Недавно у нас возникла неприятная проблема со следующим куском кода:


newAuthCode  :: (MonadWhatever m)  => DB.Client  -> DB.SessionId  -> m DB.AuthorizationCodenewAuthCode clid sid = do  let codeData = mkAuthCodeFor clid sid  void $ DB.deleteAllCodes clid sid  void $ DB.insertAuthCode codeData  return code

Здесь реализовывался обработчик HTTP-запроса и предполагалось, что функция будет обращаться к базе данных, удалять все существующие коды авторизации пользователя и записывать новый. По большому счету, код именно это и делал. Однако он также медленно заполнял наши логи сообщениями нарушение требования уникальности (uniqueness constraint violation).


Как это получилось?


Моделирование


Проблема, указанная выше, представляет собой хороший пример задачи для Alloy. Давайте попробуем представить ее, построив модель. Обычно мы начинаем моделирование конкретной проблемы с описания нашего представления об операциях newAuthCode для Alloy. Иными словами, необходимо сначала построить модель операций, затем дополнить ее, построив модель базы данных и привязав поведение базы данных к операциям.


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


Процесс, описанный в приведенном фрагменте кода, имеет две интересующие нас части. Он производит удаление в некоторый момент времени, а затем вставляет новый токен в другой момент времени. Вот одна из моделей Alloy, используемая для описания этого поведения:


open util/time  // Импортируем предопределённые объекты Timesig Operation       // У нас есть операции...  { delete : Time   // ...которые удаляют в какой-то момент времени  , insert : Time   // ...и производят вставку в какой-то другой  }  { lt[delete,insert]  // Удаления происходят до вставок    lt[first,delete]   // По техническим причинам в первый                        // момент времени ничего не происходит  }  run {some Operation} for 4 // Показать произвольный пример модели                             // с <= 4 операциями

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


Если вы хотите проследить этот процесс, скачайте alloy и скопируйте в него приведенный выше фрагмент кода. Затем нажмите 'execute' и 'show', чтобы получить модель следующего вида:



Чтобы Alloy показал другие модели, можно нажать 'next'.


Вот один из таких случайных экземпляров, представленный в виде таблицы отношений (нужно несколько раз нажать 'next и выбрать вид 'Table'):


this/OperationdeleteinsertOperation    Time Time   Operation удаляет в момент Time и   вставляет в момент TimeOperation    Time Time   Operation удаляет в момент Time и   и вставляет в момент Time                                                 ОЙ!

Как правило, на данном этапе мы начинаем моделировать таблицы базы данных и семантику операций, но оказалось, что Alloy уже смог показать, почему наши логи содержат нарушение требований!


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


Проблема найдена!


Давайте её исправим!


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


code <- run $ do  handleJust constraintViolation    (launchPG $ selectCodeForSession clid scope sid    (launchPG . pgWithTransaction $ newAuthCode clid scope sid)

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


Будет ли это работать сейчас?


Давайте быстро построим модель Alloy для нашего исправления, чтобы проверить его корректность:


open util/time // Импортируем Timesig Token {} // Объекты с названием Tokenone sig DBState // База данных с токенами {userToken : Token lone -> Time}    // В БД не более одного токена в каждый момент врвмени    // (т.к. ограничения БД не позволяют хранить больше одного)sig Operation {   delete : Time , insert : Time , select : Time // Наши операции теперь могут выполнять select}{  lt[first,delete]   // Ничего не происходит в первый момент времени                     // по техническим причинам  lt[delete,insert]  // Первой выполняется операция delete  lte[insert,select] // select выполняется после или во время insert'а  no userToken.(insert.prev) // Если вставка сработала (т.е. таблица  => insert = select         // была пустой во время выполнения),                             // получаем значение в тот же самый                              // момент времени (т.е. у нас запрос                             // 'INSERT RETURNING').                             // В противном случае вызываем обработчик                             // исключения, и select выполняется чуть позже}

До этого момента модель очень похожа на предыдущую. Мы добавили DBState для моделирования таблицы, где хранятся наши токены, и наши операции теперь выполняют select, так же, как и в нашем коде. То есть если таблица пуста, мы получаем токен во время его вставки, а если таблица заполнена, мы выбираем его позднее в обработчике исключений.


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


fact Trace {                           // Факт Trace описывает поведение системы all t : Time - first | {              // на всех шагах, кроме первого:   some delete.t => no userToken.t       // Если происходит удаление, таблица пуста   some insert.t => some userToken.t     // Если происходит вставка, таблица не пуста   no delete.t and no insert.t           // Если не происходит ни вставок, ни удалений,    => userToken.t = userToken.(t.prev)  // таблица не меняется  }}

То есть мы описываем, как состояние базы данных изменяется в зависимости от некоторых происходящих событий.


Выполнение этой модели приводит к созданию многочисленных экземпляров, но вопреки обыкновению, их простой просмотр не позволяет найти очевидную ошибку. Однако мы можем потребовать от Alloy проверить для нас некоторые факты. Здесь придется немного подумать, но кажется, что исправление будет работать, если правильно работают все вызовы select.


Давайте примем это за утверждение и попросим Alloy проверить его.


assert selectIsGood {         // То, что мы хотим проверить all s : Operation.select |   // Всегда, когда выполняется select,  some userToken.s            // в базе присутствуем токен}check selectIsGood for 6 // Проверить, что selectIsGood всегда истинно

К сожалению, запуск этой проверки дает нам следующий контрпример:


DBState userToken   DBStateTokenTime                 Token находится в БД в моменты Time и Time              Time                TokenTime   Token в БД в момент Time.                                    Токены есть в таблице только                 моменты Time, Time и Time                 Заменит, что в момент                 Time токенов нет!Operation     deleteinsertselectOperation     TIME Time TimeOperation     Time Time TIME    Таблица пуста в момент Time и     select не работает для Operation!Operation     Time Time Time                                               Это моменты времени, когда                происходят соответствующие действия

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


Итак, предлагаемое исправление, которое было проверено на согласование типов, протестировано, прошло интеграцию и проверку коллегами, оказалось ошибочным!


Параллельная обработка легко не дается.


Мои выводы


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


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


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


Где взять Alloy?


Для тех, кто заинтересовался, ниже мы приводим несколько ссылок, чтобы начать работу с Alloy:





P.S. Правильное решение этой проблемы заключается в том, чтобы использовать сериализуемые транзакции и не возиться с параллельной обработкой.

Подробнее..

Пара слов о спецификациях

30.01.2021 20:16:42 | Автор: admin

Всем доброго времени суток! Удивительно, но упоминание о шаблоне "Спецификация" в контексте php встречается крайне редко. А ведь с его помощью можно не только избежать комбинаторного взрыва методов репозитория, но и улучшить переиспользование кода. Я же в свою очередь хотел бы остановиться на еще одной возможности, предоставляемой данным паттерном. С ее помощью можно решить проблему, которая возникает почти в каждом веб-приложении. И лично мне очень не хватало этого знания еще пару лет назад.


Что будем делать


Предположим, что мы разрабатываем task tracker. На главной странице будет выводиться список задач. Также нам понадобится просмотр отдельной задачи.


TaskController.php
<?phpdeclare(strict_types=1);namespace App\Controller;use App\Entity\Task;use App\Repository\TaskRepository;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;#[Route('/task')]final class TaskController extends AbstractController{    #[Route('/', name: 'task_index', methods: ['GET'])]    public function index(TaskRepository $taskRepository): Response    {        return $this->render('task/index.html.twig', [            'tasks' => $taskRepository->findAll(),        ]);    }    #[Route('/{id}', name: 'task_show', methods: ['GET'])]    public function show(Task $task): Response    {        return $this->render('task/show.html.twig', [            'task' => $task,        ]);    }}

Далее предположим, что у нас есть 3 типа пользователей:


  • Admin может работать со всеми задачами.
  • Manager может работать только с задачами своего проекта.
  • Developer может работать только с назначенными ему задачами.

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


TaskController.php
namespace App\Controller; use App\Entity\Task;+use App\Entity\User; use App\Repository\TaskRepository;+use App\Security\CurrentUserProvider;+use Doctrine\ORM\QueryBuilder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response;+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route;+use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController {+    public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider)+    {+    }+     #[Route('/', name: 'task_index', methods: ['GET'])]     public function index(TaskRepository $taskRepository): Response     {+        $queryBuilder = $taskRepository->createQueryBuilder('t');+        $this->filter($queryBuilder);+         return $this->render('task/index.html.twig', [-            'tasks' => $taskRepository->findAll(),+            'tasks' => $queryBuilder->getQuery()+                ->getResult(),         ]);     }+    private function filter(QueryBuilder $queryBuilder): void+    {+        if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) {+            return;+        }++        $user = $this->currentUserProvider->getUser();++        if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) {+            $queryBuilder->andWhere('t.project in(:projects)')+                ->setParameter('projects', $user->getProjects());++            return;+        }++        $queryBuilder->andWhere('t.performedBy = :performedBy')+            ->setParameter('performedBy', $user);+    }+     #[Route('/{id}', name: 'task_show', methods: ['GET'])]     public function show(Task $task): Response     {+        if (!$this->isViewable($task)) {+            throw new AccessDeniedHttpException();+        }+         return $this->render('task/show.html.twig', [             'task' => $task,         ]);     }++    private function isViewable(Task $task): bool+    {+        if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) {+            return true;+        }++        $user = $this->currentUserProvider->getUser();++        if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) {+            return $user->getProjects()+                ->contains($task->getProject());+        }++        return $task->getPerformedBy() === $user;+    } }

Конечно, писать много кода в контроллере это не очень хорошо. Можно так или иначе раскидать его по сервисам, задействовать стандартные symfony voters. Но основная проблема этого кода в том, что наши бизнес-правила полностью повторяются и в методе filter, и в методе isViewable. И исправление этого факта уже не выглядит столь очевидно. Что можно с этим сделать? Нам нужна абстракция бизнес-правила, работающая как для списка элементов, так и для отдельной сущности. Именно это и предоставляет шаблон "Спецификация".


Пишем Спецификацию


В настоящий момент я нашел 2 проекта, реализующих данный паттерн для php. Happyr/Doctrine-Specification и K-Phoen/rulerz. При этом первый не поддерживает работу с отдельными объектами, а второй фактически заброшен и на symfony 5 уже не устанавливается. Да и формирование правил в строке, признаться, мне не слишком нравится.


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


Specification.php
<?phpdeclare(strict_types=1);namespace App\Specification;use Doctrine\ORM\QueryBuilder;use Symfony\Component\PropertyAccess\PropertyAccess;abstract class Specification{    abstract public function isSatisfiedBy(object $entity): bool;    abstract public function generateDql(string $alias): ?string;    abstract public function getParameters(): array;    public function modifyQuery(QueryBuilder $queryBuilder): void    {    }    public function filter(QueryBuilder $queryBuilder): void    {        $this->modifyQuery($queryBuilder);        $alias = $queryBuilder->getRootAliases()[0];        $dql = $this->generateDql($alias);        if (null === $dql) {            return;        }        $queryBuilder->where($dql);        foreach ($this->getParameters() as $field => $value) {            $queryBuilder->setParameter($field, $value);        }    }    protected function getFieldValue(object $entity, string $field): mixed    {        return PropertyAccess::createPropertyAccessorBuilder()            ->enableExceptionOnInvalidIndex()            ->getPropertyAccessor()            ->getValue($entity, $field);    }}

Помимо базовых в спецификации присутствуют вспомогательные методы. Метод filter упрощает ее применение к объекту query builder. Метод getFieldValue
пригодится нам при создании операций.


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


CompositeSpecification.php
<?phpdeclare(strict_types=1);namespace App\Specification;use Doctrine\ORM\QueryBuilder;abstract class CompositeSpecification extends Specification{    abstract public function getSpecification(): Specification;    public function isSatisfiedBy(object $entity): bool    {        return $this->getSpecification()            ->isSatisfiedBy($entity);    }    public function generateDql(string $alias): ?string    {        return $this->getSpecification()            ->generateDql($alias);    }    public function getParameters(): array    {        return $this->getSpecification()            ->getParameters();    }    public function modifyQuery(QueryBuilder $queryBuilder): void    {        $this->getSpecification()            ->modifyQuery($queryBuilder);    }}

И еще нам понадобятся несколько стандартных спецификаций, реализующих базовые операции.


AlwaysSpecified.php
<?phpdeclare(strict_types=1);namespace App\Specification;final class AlwaysSpecified extends Specification{    public function isSatisfiedBy(object $entity): bool    {        return true;    }    public function generateDql(string $alias): ?string    {        return null;    }    public function getParameters(): array    {        return [];    }}

Equals.php
<?phpdeclare(strict_types=1);namespace App\Specification;final class Equals extends Specification{    public function __construct(private string $field, private mixed $value)    {    }    public function isSatisfiedBy(object $entity): bool    {        return $this->value === $this->getFieldValue($entity, $this->field);    }    public function generateDql(string $alias): ?string    {        return sprintf('%s.%s = :%2$s', $alias, $this->field);    }    public function getParameters(): array    {        return [            $this->field => $this->value,        ];    }}

MemberOf.php
<?phpdeclare(strict_types=1);namespace App\Specification;final class MemberOf extends Specification{    public function __construct(private string $field, private object $value)    {    }    public function isSatisfiedBy(object $entity): bool    {        return $this->getFieldValue($entity, $this->field)            ->contains($this->value);    }    public function generateDql(string $alias): ?string    {        return sprintf(':%2$s member of %1$s.%2$s', $alias, $this->field);    }    public function getParameters(): array    {        return [            $this->field => $this->value,        ];    }}

Not.php
<?phpdeclare(strict_types=1);namespace App\Specification;final class Not extends Specification{    public function __construct(private Specification $specification)    {    }    public function isSatisfiedBy(object $entity): bool    {        return !$this->specification            ->isSatisfiedBy($entity);    }    public function generateDql(string $alias): ?string    {        return sprintf(            'not (%s)',            $this->specification->generateDql($alias)        );    }    public function getParameters(): array    {        return $this->specification            ->getParameters();    }}

Добавлять их можно по мере необходимости. Чуть хитрее обстоит дело с объединением таблиц. Я попробовал несколько вариантов и в итоге остановился на этом.


Join.php
<?phpdeclare(strict_types=1);namespace App\Specification;use Doctrine\ORM\QueryBuilder;final class Join extends Specification{    public function __construct(private string $rootAlias, private string $field, private Specification $specification)    {    }    public function isSatisfiedBy(object $entity): bool    {        return $this->specification            ->isSatisfiedBy($this->getFieldValue($entity, $this->field));    }    public function generateDql(string $alias): ?string    {        return $this->specification            ->generateDql($this->field);    }    public function getParameters(): array    {        return $this->specification            ->getParameters();    }    public function modifyQuery(QueryBuilder $queryBuilder): void    {        $queryBuilder->join(sprintf('%s.%s', $this->rootAlias, $this->field), $this->field);        $this->specification            ->modifyQuery($queryBuilder);    }}

Переходим на бизнес-правила


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


IsViewable.php
<?phpdeclare(strict_types=1);namespace App\Specification\Task;use App\Entity\User;use App\Security\CurrentUserProvider;use App\Specification\AlwaysSpecified;use App\Specification\CompositeSpecification;use App\Specification\Equals;use App\Specification\Join;use App\Specification\MemberOf;use App\Specification\Specification;use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;final class IsViewable extends CompositeSpecification{    public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider)    {    }    public function getSpecification(): Specification    {        if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) {            return new AlwaysSpecified();        }        $user = $this->currentUserProvider->getUser();        if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) {            $isProjectMember = new MemberOf('members', $user);            return new Join('task', 'project', $isProjectMember);        }        return new Equals('performedBy', $user);    }}

А вот в контроллере кода поубавится.


TaskController.php
namespace App\Controller; use App\Entity\Task;-use App\Entity\User; use App\Repository\TaskRepository;-use App\Security\CurrentUserProvider;-use Doctrine\ORM\QueryBuilder;+use App\Specification\Task\IsViewable; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route;-use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; #[Route('/task')] final class TaskController extends AbstractController {-    public function __construct(private AuthorizationCheckerInterface $authorizationChecker, private CurrentUserProvider $currentUserProvider)+    public function __construct(private IsViewable $isViewable)     {     }@@ -26,7 +23,7 @@ final class TaskController extends AbstractController     public function index(TaskRepository $taskRepository): Response     {         $queryBuilder = $taskRepository->createQueryBuilder('t');-        $this->filter($queryBuilder);+        $this->isViewable->filter($queryBuilder);         return $this->render('task/index.html.twig', [             'tasks' => $queryBuilder->getQuery()@@ -34,29 +31,10 @@ final class TaskController extends AbstractController         ]);     }-    private function filter(QueryBuilder $queryBuilder): void-    {-        if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) {-            return;-        }--        $user = $this->currentUserProvider->getUser();--        if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) {-            $queryBuilder->andWhere('t.project in(:projects)')-                ->setParameter('projects', $user->getProjects());--            return;-        }--        $queryBuilder->andWhere('t.performedBy = :performedBy')-            ->setParameter('performedBy', $user);-    }-     #[Route('/{id}', name: 'task_show', methods: ['GET'])]     public function show(Task $task): Response     {-        if (!$this->isViewable($task)) {+        if (!$this->isViewable->isSatisfiedBy($task)) {             throw new AccessDeniedHttpException();         }@@ -64,20 +42,4 @@ final class TaskController extends AbstractController             'task' => $task,         ]);     }--    private function isViewable(Task $task): bool-    {-        if ($this->authorizationChecker->isGranted(User::ROLE_ADMIN)) {-            return true;-        }--        $user = $this->currentUserProvider->getUser();--        if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) {-            return $user->getProjects()-                ->contains($task->getProject());-        }--        return $task->getPerformedBy() === $user;-    } }

Отлично! Повторения кода больше нет. Но что если мы усложним условия?
Представим, что в списке у менеджера и разработчика должны выводиться только задачи, статус проекта которых не равен "archived".


IsViewable.php
use App\Entity\User; use App\Security\CurrentUserProvider; use App\Specification\AlwaysSpecified;+use App\Specification\AndX; use App\Specification\CompositeSpecification; use App\Specification\Equals; use App\Specification\Join; use App\Specification\MemberOf;+use App\Specification\Not;+use App\Specification\Project\IsArchived; use App\Specification\Specification; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;@@ -26,14 +29,23 @@ final class IsViewable extends CompositeSpecification             return new AlwaysSpecified();         }+         $isNotArchived = new Not(new IsArchived());          $user = $this->currentUserProvider->getUser();         if ($this->authorizationChecker->isGranted(User::ROLE_MANAGER)) {             $isProjectMember = new MemberOf('members', $user);-            return new Join('task', 'project', $isProjectMember);+            return $this->getProjectSpecification(new AndX($isNotArchived, $isProjectMember));         }-        return new Equals('performedBy', $user);+        return new AndX(+            new Equals('performedBy', $user),+            $this->getProjectSpecification($isNotArchived)+        );+    }++    private function getProjectSpecification(Specification $specification): Join+    {+        return new Join('task', 'project', $specification);     } }

Выводы


Безусловно реализация данного паттерна в моем исполнении прямолинейна и очень наивна. Будут возникать вопросы с коллизией имен, да и с объединением таблиц все вероятно сложнее. Однако я пока не вижу принципиально нерешаемых проблем. Да и такая простая реализация уже способна приносить пользу. Количество условий в задаче можно увеличивать и дальше. Вынося их в процессе в отдельные спецификации и комбинируя по своему усмотрению. Но главное остается неизменным каждая спецификация по-прежнему работает как для фильтрации на уровне БД, так и для отдельной сущности. И лично мне не известны другие способы добиться того же. Буду рад, если кто-нибудь упомянет о них в комментариях.


Да и вообще, что вы думаете о данном паттерне? Почему он так мало представлен в php? И можно ли ожидать, что он станет стандартом на уровне фреймворков?


С полным примером из статьи можно ознакомиться на github.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru