Практика: плата на производстве (Production line)
Печатная плата идёт по этапам монтаж -> контроль -> прошивка -> упаковка, а сканеры на участках присылают одни и те же четыре действия: что значит "передать дальше" или "вернуть на доработку", решает этап, на котором плата сейчас.
Базовый пример: Состояние. Там у плеера три режима и кнопки, которые в неподходящем режиме молча ничего не делают. Здесь кнопок тоже немного, но каждое действие сканера - либо переход с записью в историю, либо отказ с причиной, у каждого этапа есть своё подсостояние "ждёт" или "в работе", а одно правило зависит от прошлого платы: сколько раз её уже возвращали.
Задача
Контрактное производство собирает платы для заказчиков. Плата проходит четыре участка: монтаж компонентов, визуальный контроль, прошивка, упаковка. На каждом участке оператор сканирует штрихкод платы и жмёт одну из четырёх кнопок терминала: "принять" (взял в работу), "передать дальше" (закончил), "отбраковать" и "вернуть на доработку". Сканер присылает в систему только серийный номер и действие.
Сначала статус платы был полем, а проверки жили в обработчике сканера: if status == 'inspection' and action == 'rework'. Потом выяснилось, что операторы передают дальше платы, которые не принимали, и в отчёте участка пропадает время работы. Технолог запретил возвращать на доработку больше двух раз: после третьей перепайки плата идёт в брак, потому что дорожки отслаиваются от нагрева. Заказчик попросил по каждой плате историю: кто и куда её двигал. Каждое правило добавляло if в обработчик, и разобраться, что можно делать с платой на прошивке, стало невозможно без отладчика.
Отличие от первой практики: там у каждого перехода своё событие (pay, ship), и вопрос в том, разрешено ли оно. Здесь действий четыре на все этапы, и вопрос в том, куда ведёт одно и то же действие с этого этапа.
Что урезано
- Нет операторов, участков и времени: в истории только "откуда, действие, куда". В проекте к переходу добавляют табельный номер и время, форма та же.
- Нет хранилища: платы живут в памяти, восстановление из базы показано в первой практике.
- Причина брака не передаётся, отбраковка - одно действие без кода дефекта.
- Сканер - строка "серийный номер и действие", без протокола терминала.
Решение
Этап платы - объект-состояние. Базовое BoardState запрещает все четыре действия (accept, pass, reject, rework) и бросает IllegalScan с текстом board B-7: cannot rework at flashing. Конечные этапы "отгружена" и "брак" - это EndStage: он ничего не переопределяет, поэтому из него запрещено всё.
Четыре этапа линии ведут себя одинаково в главном: плату надо принять, прежде чем передать дальше, и отбраковать её можно с любого этапа. Это общее поведение живёт в LineStage вместе с флагом inWork - вложенным состоянием "ждёт / в работе". Конкретные этапы Assembly, Inspection, Flashing, Packing говорят только, какой этап следующий (next). Inspection единственный разрешает rework и сам решает, куда вернуть плату: на монтаж или, если лимит доработок исчерпан, в брак.
Board - контекст. Он превращает строку сканера в действие, отдаёт его текущему этапу и записывает Transition в историю. Если этап отказал, исключение вылетает до записи: история и этап не меняются. Число доработок не хранится отдельным полем: reworks() считает их по истории, и правило лимита читает то же, что видит заказчик.
Структура
На диаграмме переходов accept не виден как отдельная стрелка: он не меняет этап, а переводит плату из "ждёт" в "в работе" внутри этапа. В истории такой переход есть: inspection -accept-> inspection (in work).
Вложенное состояние и правило по истории
Флаг inWork можно было бы сделать ещё четырьмя классами (AssemblyInWork и так далее), но тогда правила "принять дважды нельзя" и "передать без приёма нельзя" повторились бы в восьми местах. Это классическая развилка State: когда у всех состояний есть общее подсостояние, его держит общий родитель, а состояния-наследники описывают только то, чем они различаются. Принятая плата - тот же класс этапа с inWork = true, поэтому Inspection в работе по-прежнему умеет rework.
Лимит доработок - правило, которое зависит не от текущего этапа, а от пути платы. Состояние получает Board параметром и спрашивает reworks(); поле-счётчик рядом с историей легко разошлось бы с ней, а история уже есть. Третья доработка всё равно записывается в историю, но ведёт в scrapped: по истории видно, почему плата ушла в брак.
Как это собирается
Плата создаётся, когда партию выпускают на линию: система заводит Board на каждый серийный номер, все стоят на монтаже и ждут. Дальше сервер терминалов получает строки от сканеров в порядке прихода, находит плату по серийному номеру и зовёт scan с кодом действия. Сервер не знает правил этапов: он только показывает оператору отказ красным на терминале, если scan бросил ошибку.
В примере две платы за смену. B-1 проходит весь маршрут, по дороге оператор прошивки по ошибке жмёт "вернуть на доработку" и получает отказ - доработка разрешена только с контроля. B-2 с контроля возвращается на монтаж, потом кто-то сканирует несуществующее действие skip, и в конце плату отбраковывают. В выводе отказы, итог по обеим платам и история B-2.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Состояния неизменяемы: accept возвращает новый объект того же этапа с inWork = true, а не меняет флаг у текущего. Тексты отказов, лимит в две доработки и формат истории одинаковы во всех языках.
| Язык | Особенность |
|---|---|
| PHP | enum Stage и enum Action, Action::tryFrom для строки сканера; абстрактные BoardState и LineStage, new static(true) при final конструкторе; match по действию исчерпывающий |
| Go | Наследования нет: lineStage - одна структура, а различия этапов приходят функциями next и rework; конструкторы assembly(), inspection() собирают этапы; ошибка - *ScanError для errors.As |
| Python | StrEnum, базовый класс с запретами, LineStage - frozen dataclass, replace(self, in_work=True) сохраняет класс наследника; метод pass_, потому что pass - ключевое слово |
| TypeScript | Состояния - объекты из фабрик; тип Record<Action, ...> делает имена методов равными действиям, и Board вызывает this.state[code](this) без switch |
Запуск: make test T=patterns/behavioral/state/practice-production
Что проверяют тесты
Тесты одинаковы во всех языках, маршрут платы - строка действий, как в базовом примере.
- Куда ведёт действие. Семь маршрутов: новая плата ждёт на монтаже,
acceptберёт её в работу,accept passведёт на контроль, полный маршрут заканчиваетсяshipped, доработка возвращает на монтаж,rejectс прошивки ведёт в брак, третья доработка - тоже в брак. - Отказы. Пять случаев с точным текстом: передать без приёма, принять дважды, доработка на прошивке, любое действие после брака и после отгрузки. В каждом этап и история после отказа те же, что до него.
- История. После
accept pass accept reworkв ней ровно четыре перехода с подсостоянием "в работе". - Неизвестное действие сканера падает с ошибкой, в которой есть сам код
'skip'.
Где граница
- Этапы различаются только тем, какой следующий, - это таблица маршрута, и классы этапов ей не нужны. State окупается здесь на
InspectionиEndStage: у них своё поведение. Если маршрут разный для разных изделий (у одной платы нет прошивки, у другой два контроля), маршрут уезжает в данные, а состояниями остаются "ждёт", "в работе", "конец". - Нужны параллельные операции (прошивка и лазерная маркировка одновременно) - один объект-состояние их не выразит, это уже иерархический автомат или workflow-движок.
- Правил по истории становится много (лимиты по участкам, по времени, по оператору) - их выносят в отдельные проверки перед переходом, а состояние только спрашивает их.
- Если действие на переходе должно что-то сделать снаружи (напечатать этикетку, списать компоненты), это ложится в метод состояния, как деньги в первой практике.
Отличия между языками
- PHP - два уровня абстрактных классов:
BoardStateдаёт запреты,LineStage- приём и передачу.new static(true)создаёт принятую копию того же этапа; PHPStan пропускает его только приfinalконструкторе.Action::tryFromвозвращаетnullна незнакомую строку, и?? throwпревращает это в понятную ошибку. - Go - наследования нет, и вместо четырёх структур с одинаковыми
AcceptиPassоднаlineStageс полями-функциями:nextиrework(nilзначит "нельзя"). Это всё ещё State: контекст держит интерфейсStateи делегирует ему, а значениеlineStageсinWork = trueкопируется, а не меняется. - Python -
LineStage- frozen dataclass, наследники задаютstageкакClassVarи не объявляют своих полей, поэтомуdataclasses.replaceсоздаёт объект того же класса.matchпоStrEnumвыбирает метод;pass_с подчёркиванием по PEP 8. - TypeScript - этап - объект с методами
accept,pass,reject,rework, а типRecord<Action, ...>гарантирует, что метод есть на каждое действие. После проверкиisActionстрока сканера становится ключом, и диспетчеризация - одна строкаthis.state[code](this).
Связанные темы
- Состояние (базовый пример)
- Практика: жизненный цикл заказа - своё событие на каждый переход, деньги на переходах, восстановление из базы
- Стратегия - та же схема делегирования, но объект выбирает клиент