Сайт Романа ПарпалакаБлог20250626

Разбираем конечный автомат в системе личных сообщений

26 июня 2025 года, 21:04

В прошлый раз я рассказывал о применении понятия конечного автомата в программировании. В этот раз рассмотрим практический пример.

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

Требование №1: черновики и уведомления о прочтении

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

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=4cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.7em, text width=7em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,font=\small\sffamily} } \node[mynode,fill=gray!10] (Draft) {Черновик\\status=draft}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Read) {Прочитано\\status=read}; \draw[myarrow] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщение} (Read); \end{tikzpicture}$$

Требование №2: отзыв отправленных сообщений

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

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

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=4cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.7em, text width=7em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,font=\small\sffamily} } \node[mynode,fill=gray!10] (Draft) {Черновик\\status=draft}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Delivered) {Доставлено\\status=delivered}; \node[mynode, right of=Delivered,fill=green!10] (Read) {Прочитано\\status=read}; \draw[myarrow] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow] (Sent) to[in=-50,out=-130] node[below,align=center] {Отправитель нажал\\«В черновики»} (Draft); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель увидел\\ кол-во сообщений} (Delivered); \draw[myarrow] (Delivered) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщение} (Read); \end{tikzpicture}$$

Если бы требовалось сделать отправку уведомлений о новых личных сообщениях по электронной почте, пришлось бы делать переход «отправлено» → «доставлено» в момент отправки письма, так как мы уже не контролируем процесс доставки этого письма и не знаем, когда оно будет прочитано.

Требование №3: удаление сообщений

По требованиям и получатель, и отправитель могут удалять сообщения. В свое время я сделал два флага: deleted_by_sender и deleted_by_receiver. Они нужны для того, чтобы знать, от кого из участников уже надо скрыть сообщение, а от ещё кого нет. Если же они оба удаляют сообщение, то оно удаляется из базы данных целиком. (Здесь важно не повторить мою ошибку и устанавливать флаги в транзакции после блокировки строк, иначе при одновременном удалении отправителем и получателем получим оба установленных флага вместо полного удаления сообщения.)

С одной стороны флаги deleted_by_sender и deleted_by_receiver выглядят красиво и симметрично. Но с другой стороны, если вы внимательно читали предыдущий пост, то уже догадались, что набор из трех полей (status, deleted_by_sender и deleted_by_receiver) — не самое лучшее решение для кодирования состояния.

Первая проблема этих флагов и поля состояния заключается в том, что некоторые наборы значений (например, status = draft и deleted_by_receiver = 1) не соответствуют ни одному допустимому состоянию (черновик не может иметь отметку об удалении получателем, потому что получатель ничего не получал). Вторая проблема проявляется в повторении в коде одних и тех же условий. Так, условие deleted_by_receiver = 0 AND (status = 'delivered' OR status = 'read'), которое соответствует доступным получателю сообщениям, повторяется в коде три раза.

Что же делать с признаками удаления сообщений? На диаграмме состояний видно, что статусами управляет сначала отправитель, а потом получатель. Также получатель может удалить сообщение только после его доставки. Поэтому состояние «удалено получателем» вполне естественно вписывается в имеющийся набор состояний:

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=3.6cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.5em, text width=7em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,font=\small\sffamily} } \node[mynode,fill=gray!10] (Draft) {\shortstack{Черновик\\status=draft}}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Delivered) {\shortstack{Доставлено\\status=delivered}}; \node[mynode, right of=Delivered,fill=green!10] (Read) {Прочитано\\status=read}; \node[mynode, right of=Read,fill=red!10] (Deleted) {Удалено\\status=deleted}; \draw[myarrow] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow] (Sent) to[in=-50,out=-130] node[below,align=center] {Отправитель нажал\\«В черновики»} (Draft); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель увидел\\ кол-во сообщений} (Delivered); \draw[myarrow] (Delivered) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщение} (Read); \draw[myarrow] (Read) to[in=130,out=50] node[above,align=center] {Получатель удалил\\ сообщение} (Deleted); \end{tikzpicture}$$

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

С учетом этих требований диаграмма всех возможных состояний приобретает следующий вид:

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=3.6cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.5em, text width=7.0em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,orange!70!black,font=\small\sffamily}, sender/.style={blue!80!black} } \clip (-10,10) rectangle (20,-8); %костыль \node[mynode,fill=gray!10] (Draft) {Черновик\\status=draft}; \node[mynode,above of=Draft,dashed] (New) {В БД пусто}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Delivered) {Доставлено\\status=delivered}; \node[mynode, below of=Delivered,fill=yellow!10] (DeliveredAndDeleted) {\shortstack{Доставл. и удал.}\\status=delivered\\del\_by\_sendr=1}; \node[mynode, right of=Delivered,fill=green!10] (Read) {Прочитано\\status=read}; \node[mynode, below of=Read,fill=yellow!10] (ReadAndDeleted) {Прочит. и удал.\\status=read\\del\_by\_sendr=1}; \node[mynode, right of=Read,fill=yellow!10] (Deleted) {Удалено\\status=deleted}; \node[mynode, below of=Deleted,dashed] (Deleted2) {Удалено из БД}; \draw[myarrow,sender] (New) to[] node[left,align=right] {Отправитель\\нажал\\«В черновики»} (Draft); \draw[myarrow,sender] (New) to[out=0,in=90] node[right,pos=0.2,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow,sender] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель\\нажал\\«Отправить»} (Sent); \draw[myarrow,sender] (Sent) to[in=-50,out=-130] node[below,align=center] {Отправитель\\нажал\\«В черновики»} (Draft); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель увидел\\ кол-во сообщений} (Delivered); \draw[myarrow] (Delivered) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщ.} (Read); \draw[myarrow] (Read) to[in=130,out=50] node[above,align=center] {Получатель удалил\\ сообщ.} (Deleted); \draw[myarrow,sender] (Delivered) to node[left,align=right] {Отправитель\\удалил\\сообщ.} (DeliveredAndDeleted); \draw[myarrow,sender] (Read) to node[left,align=right] {Отправитель\\удалил\\сообщ.} (ReadAndDeleted); \draw[myarrow,sender] (Deleted) to node[left,align=right] {Отправитель\\удалил\\сообщ.} (Deleted2); \draw[myarrow,sender] (Draft) to[in=-80,out=-90,looseness=1.2] node[below,pos=0.6] {Отправитель удалил сообщ.} (Deleted2); \draw[myarrow,sender] (Sent) to[in=-90,out=-90,looseness=1.3] node[below,pos=0.58] {Отправитель удалил сообщ.} (Deleted2); \draw[myarrow] (DeliveredAndDeleted) to[in=-130,out=-50] node[below,align=center] {Получатель открыл\\ сообщ.} (ReadAndDeleted); \draw[myarrow] (ReadAndDeleted) to[in=-130,out=-50] node[below,align=center,pos=0.4] {Получатель удалил\\ сообщ.} (Deleted2); \end{tikzpicture}$$

Я добавил на диаграмму начальное состояние, когда в базе данных ещё нет сообщения и отправитель сохраняет его впервые, а также конечное состояние, когда оба участника переписки удалили сообщение. В принципе, никто не запрещает ввести отдельные коды для состояний «доставлено получателю и удалено отправителем» и «прочитано получателем и удалено отправителем», чтобы состояние кодировалось всего одним полем status. Но я бы оставил флаг deleted_by_sender отдельно от поля status, чтобы смысл данных в таблице был интуитивно понятным.

Выводы и анализ корректности требований

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

Проанализируем корректность и полноту диаграммы из нашего примера. На диаграмме отсутствует переход между доставленным и удаленным состоянием сообщения. Должен ли он существовать? Должен, если получатель может удалить сообщение из списка, не открывая его. Должна ли система отображать отправителю, что получатель, не прочитав, удалил сообщение? Если должна, то одного состояния «удалено» недостаточно, так как мы теряем информацию о прочтении удаленных сообщений. Возможно, в этом случае не стоило удалять флаг deleted_by_receiver и заменять его на status=deleted.

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

Поделиться

Сложение вращений и анимация в TikZ Ctrl Нативное gzip-сжатие в JS

Читайте также

Восстановление текстов в новой версии S2
В новой версии движка S2, которую я выпустил вчера, появилось очень важное нововведение.
2011
Отключение голосовой почты на Мегафоне
Голосовая почта (автоответчик) — совершенный анахронизм.
2020
Радиоприемник в голове
На моем любимом форуме по физике прочитал сообщение. Цитата:
2006

Оставьте свой комментарий


Формулы на латехе: $$f(x) = x^2-\sqrt{x}$$ превратится в $$f(x) = x^2-\sqrt{x}$$.
Выделение текста: [i]курсивом[/i] или [b]жирным[/b].
Цитату оформляйте так: [q = имя автора]цитата[/q] или [q]еще цитата[/q].
Других команд или HTML-тегов здесь нет.

Записи