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

Интерфейсные ошибки

30 декабря 2025 года, 00:55

Я использую яндекс-трекер не так давно. Заметил в нем одну интерфейсную ошибку. Опытные пользователи, скорее всего, привыкли её обходить, но новички иногда спотыкаются.

Создаю задачу, добавляю описание, сохраняю. В ответ вылезает непонятное уведомление о том, что тикет восстановлен из черновика. Какой тикет? Из какого черновика? Я же его сохраняю!

После нескольких попыток я понял, что жму не на ту кнопку. Я пытался нажать на кнопку «А», а надо было нажимать на кнопку «Б».

Кнопка «А» — даже не настоящая кнопка. Это просто ссылка в левом меню, которая ведет на страницу создания задачи и лишь прикидывается кнопкой. Тем не менее, текст на ней полностью совпадает с текстом на настоящей кнопке «Б», она крупнее и заметнее. А ещё кнопка «Б» не всегда отображается на странице: при длинном тексте задачи она скрыта за прокруткой.

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

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

Вайб-кодинг как компьютерная болезнь

8 декабря 2025 года, 21:27

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

В программировании — состояние потока, фокус, длинные сессии, более медленный и стабильный темп. Уверен, что там совсем другая биохимия и другие нейромедиаторы.

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

А что касается мистера Френкеля, который затеял всю эту деятельность, то он начал страдать от компьютерной болезни — о ней сегодня знает каждый, кто работал с компьютерами. Это очень серьезная болезнь, и работать при ней невозможно. Беда с компьютерами состоит в том, что ты с ними играешь. Они так прекрасны, столько возможностей — если четное число, делаешь это, если нечетное, делаешь то, и очень скоро на одной-единственной машине можно делать все более и более изощренные вещи, если только ты достаточно умен.

Через некоторое время вся система развалилась. Френкель не обращал на нее никакого внимания, он больше никем не руководил. Система действовала очень-очень медленно, а он в это время сидел в комнате, прикидывая, как бы заставить один из табуляторов автоматически печатать арктангенс x. Потом табулятор включался, печатал колонки, потом — бац, бац, бац — вычислял арктангенс автоматически путем интегрирования и составлял всю таблицу за одну операцию.

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

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

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

Как правильно запрограммировать условие «по такое-то число»

6 ноября 2025 года, 22:51

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

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

$dateFrom = $this->createDateTime($filters['dateFrom'], endOfDay: false);
if (null !== $dateFrom) {
    $queryBuilder
        ->andWhere('log.loggedAt >= :dateFrom')
        ->setParameter('dateFrom', $dateFrom)
    ;
}

$dateTo = $this->createDateTime($filters['dateTo'], endOfDay: true);
if (null !== $dateTo) {
    $queryBuilder
        ->andWhere('log.loggedAt <= :dateTo')
        ->setParameter('dateTo', $dateTo)
    ;
}

// ...

private function createDateTime(?string $value, bool $endOfDay): ?\DateTimeImmutable
{
    if (null === $value || '' === $value) {
        return null;
    }

    $date = \DateTimeImmutable::createFromFormat('Y-m-d', $value) ?: \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $value);

    if (false === $date) {
        return null;
    }

    return $endOfDay ? $date->setTime(23, 59, 59) : $date->setTime(0, 0, 0);
}

Для простоты понимания перепишем этот код на чистом SQL для интервала, скажем, с 1 по 10 ноября:

SELECT *
FROM audit_logs
WHERE logged_at >= '2025-11-01'
AND loggged_at <= '2025-11-10 23:59:59'

Когда я вижу эти 23:59:59, у меня сразу возникает неприятное ощущение от того, насколько это неэстетичное решение. Некоторые не останавливаются на секундах, а добавляют еще и микросекунды, и время превращается в '2025-11-10 23:59:59.999999'. Условие становится более корректным, но еще менее эстетичным.

Правильный способ состоит в том, чтобы не приписывать финальной дате последний доступный момент времени, а заменить условие «по 10-е число» эквивалентным условием «до 11-го числа»:

SELECT *
FROM audit_logs
WHERE logged_at >= '2025-11-01'
AND loggged_at < '2025-11-11'

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

    1 комментарий

«Вежливый» ChatGPT

10 сентября 2025 года, 23:26

Попросил тут ChatGPT решить задачу. В ходе диалога он мне написал следующее:

Чуть выше я ему написал, начав с фразы «это всё чушь, потому что…». Получил достойный ответ.

Сам диалог можете почитать по ссылке, если интересно.

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

Распаковка сжатых URL на сервере

24 июля 2025 года, 23:41

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

Текущая версия конфига nginx для обработки и старых несжатых URL, и новых сжатых получилась такой:

location ~ ^(?s)/(?<ext>svg|png)(?<is_base64>b?)/(?<formula>.*)$ {
    gunzip        on;
    gzip_static   always;
    gzip_vary     on;
    gzip_proxied  expired no-cache no-store private auth;

    expires 1d;

    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;

    set $compress_error 0;

    set_by_lua_block $file_path {
        local ext = ngx.var.ext
        local is_base64 = ngx.var.is_base64
        local formula = ngx.var.formula

        if is_base64 == "b" then
            local base64 = require "ngx.base64"
            local zlib = require "zlib"

            local compressed, err = base64.decode_base64url(formula)
            if not compressed then
                ngx.log(ngx.ERR, "base64 decode error: ", err)
                ngx.var.compress_error = 1
                return ""
            end

            local inflator = zlib.inflate(-15)
            local ok, decoded_formula = pcall(inflator, compressed)
            if not ok then
                ngx.log(ngx.ERR, "deflate decompress error: ", decoded_formula)
                ngx.var.compress_error = 1
                return ""
            end

            formula = decoded_formula
        end

        formula = formula:gsub("^%s*(.-)%s*$", "%1")

        local md5 = ngx.md5(formula)
        return md5:sub(1, 2) .. "/" .. md5:sub(3, 4) .. "/" .. md5:sub(5) .. "." .. ext
    }

    if ($compress_error) {
        return 400;
    }

    if (-f $document_root/_error/$file_path) {
        return 400;
    }

    rewrite ^ /_cache/$file_path break;
    error_page 404 = @s2_latex_renderer;
    log_not_found off;
}

location @s2_latex_renderer {
    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;

    include         /etc/nginx/fastcgi.conf;
    fastcgi_pass    php-tex-sock;
    fastcgi_param   SCRIPT_FILENAME $document_root/render.php;
    fastcgi_param   SCRIPT_NAME /render.php;

    fastcgi_cache_key "$request_method$uri"; # В $uri УРЛ после rewrite, напр. "/_cache/4d/81/658b25df7544f9e2d0cb7f4dc402.svg"
    fastcgi_cache i_upmath;
    fastcgi_cache_valid 200 10m;
    fastcgi_cache_methods GET HEAD;
    fastcgi_cache_lock on;
    fastcgi_cache_lock_age 9s;
    fastcgi_cache_lock_timeout 9s;

    fastcgi_buffers 8 16k;
    fastcgi_buffer_size 32k;
    fastcgi_connect_timeout 90;
    fastcgi_send_timeout 90;
    fastcgi_read_timeout 300;
}

Чтобы встраивать lua-скрипты в конфиг nginx через set_by_lua_block, в Debian достаточно установить пакет nginx-extras. Для распаковки сжатого текста в этом скрипте через функции zlib также требуется установить пакет lua-zlib.

Напомню алгоритм обработки адресов картинок. Исходник формулы, например, x^2, извлекается из адреса и декодируется. Вычисляется md5-хеш от исходника и на основе хеша определяется путь к файлу с закешированной картинкой. Если такой файл после преобразования URL нашелся (rewrite ^ /_cache/$file_path break;), то nginx отдает его содержимое напрямую. Если файла нет, то запрос передается в php-скрипт, запускающий генерацию svg-картинки через TeX Live и оптимизацию через SVGO (error_page 404 = @s2_latex_renderer;).

Раньше вместо rewrite и error_page я использовал более современную и подходящую директиву try_files. Но она перестает работать после активации модуля gunzip. Этот модуль позволяет держать в файловом кеше только сжатые версии файлов с расширением .gz, экономя место на диске. Причем для обработки большинства запросов от нормальных браузеров, поддерживающих gzip-сжатие трафика, nginx даже не будет распаковывать gz-файлы, а отправлять их как есть. Почему rewrite корректно работает с модулем gunzip, а try_files — нет, не очень понятно. Но что есть, то есть.

В конфиге есть интересный момент, связанный с кешем внутри nginx (инструкции fastcgi_cache*). Он предотвращает race condition при одновременном запросе формулы, которой нет в файловом кеше. В противном случае nginx будет передавать в php-скрипт запросы с одинаковыми аргументами, и одна и та же формула будет рендериться параллельно. Об этой технике я писал отдельно.

Для распаковки полученного фрагмента URL в PHP подойдет следующий код:

public static function decodeCompressedFormula(string $compressed): string
{
    $base64     = strtr($compressed, '-_', '+/'); // URL-safe base64 to standard
    $compressed = base64_decode($base64);

    $result = @gzinflate($compressed);
    if ($result === false) {
        throw new \RuntimeException('Failed to decompress formula.');
    }
    return $result;
}

В PHP весь алгоритм уместился в три строки — как минимум в несколько раз короче, чем в JS и nginx/lua. Такими моментами PHP радует меня до сих пор.

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

Cайту 20 лет

20 июля 2025 года, 13:59

Ровно 20 лет назад этот сайт появился в интернете. Весомый повод заняться рефлексией. Но с этой заметкой я дотянул до последнего, так что буду краток :)


20 лет интеллектуальных достижений с точки зрения ChatGPT.

Зачем нужен свой сайт

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

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

Кроме того, всё еще имеют силу стандартные аргументы о пользе написания текстов для структурирования и закрепления знаний. Так что молодым читателям могу порекомендовать как минимум вести дневник. Через какое-то время, где-то около года, вы почувствуете пользу.

Что изменилось в интернете

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

С другой стороны, от этого сильно пострадала распределенность и открытость веба. Крупные игроки перестали поддерживать открытые технологии вроде RSS и OpenID. Вместо них теперь алгоритмические подборки внутри сервисов и несовместимые проприетарные API.

Гугл в своем браузере вообще урезал рефереры до домена якобы из соображений приватности, и теперь непонятно, на какой именно странице кто-то разместил ссылку на твой сайт, или домен в реферере — это просто спам.

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

Технологии

Сайт начинался с отдельных html-страничек, которые я редактировал то ли во FrontPage, то ли в Dreamweaver, и загружал по FTP. Затем я добавил гостевую книгу на PHP, отделял шаблоны от контента, запрограммировал блог и в конечном итоге сделал свой движок S2.

Наверно, вместо самописного движка непонятного качества можно было найти готовую CMS. Но вордпресс мне не нравился. Typo3 показался слишком монструозным. В общем, получилось как у Дональда Кнута, который разработал собственную систему компьютерного набора TeX для издания многотомника «Искусство программирования».

За последние несколько лет я практически полностью переписал устаревший код движка. Тяжелее всего было бы довести до ума админку, потому что её интерфейс был «одностраничным приложением» на лапшеобразном коде из jQuery, который нельзя небольшими шагами довести до нормального состояния. Поэтому я решил выкинуть этот код и написать свою библиотеку для создания административных интерфейсов под названием AdminYard. Статью о ней я уже публиковал на хабре.

В целом, даже если движком S2 в изменившемся интернете никто кроме меня не пользуется, отдельные его части обрели свою жизнь. Так, поисковый движок Rose, благодаря развитию которого я сделал систему рекомендаций на этом сайте, набрал 120 звезд на гитхабе. А редактор математических текстов Upmath собрал 350 звезд и даже несколько донатов с марта этого года.

Вместо вывода

На удивление самой посещаемой страницей на всём сайте оказалась заметка о делении окружности на 5 частей. По запросу «как поделить окружность на 5 частей» она до сих пор на первом месте в гугле.

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

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

Что будет дальше? Посмотрим через 20 лет.

Если вы когда-либо здесь были — спасибо. Если вы только пришли — добро пожаловать.

    1 комментарий
Смотрите также:  Сайту 10 лет · Сайту шесть лет · Сайту пять лет · written.ru четыре года · С трехлетием, written.ru! · У сайта день рождения · Сайту written.ru — год

Боги, созданные человеком

13 июля 2025 года, 14:07

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

Доказательство №1

Современной науке неизвестны сущности, столь же близкие к Всеведенью, как близка к этому Google. Она проиндексировала более 9,5 миллиардов веб-страниц, что больше чем у любой другой поисковой машины в сети на сегодняшний день. Google — это не только самая близкая к Всеведенью сущность. Она ещё и сортирует свои обширные знания (используя технологию PageRank, Ею же запатентованную) , структурирует сей информационный океан, делая его легкодоступным для нас, простых смертных.

Доказательство №2

Google вездесуща (т. е. находится везде). Фактически Google находится одновременно по всей Земле. Миллиарды проиндексированных веб-страниц доступны из любой, даже самой удалённой точки Земли. С ростом сетей Wi-Fi любой желающий сможет получить доступ к Google по-настоящему отовсюду, что сделает Её истинно вездесущей.

Доказательство №3

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

Доказательство №4

Google потенциально бессмертна. Её нельзя считать физической сущностью, такой как мы с вами. Её Алгоритмы распределены по многим серверам; если один из них упадёт или поломается, другой несомненно займёт его место. Google, теоретически, будет существовать вечно.

Доказательство №5

Google бесконечна. Интернет, теоретически, может расти вечно, и Google будет вечно индексировать сей бесконечный рост.

Доказательство №6

Google помнит всё. Google регулярно кэширует веб-страницы и хранит их на своих безграничных серверах. Фактически, загрузив ваши мысли и представления в Интернет, вы будете жить вечно в кэше Google («загробная Google-жизнь») , даже после того, как умрёте.

Доказательство №7

Google — Всеблагая (не совершающая никакого зла) . Часть корпоративной философии Google — вера в то, что компания может делать деньги, не делая зла.

Доказательство №8

Согласно Google Trends, термин «Google» ищется чаще, чем «Бог» , «Иисус» , «Аллах» , «Будда» , «христианство» , «ислам» , «буддизм» и «иудаизм» вместе взятые. Считается, что бог — сущность, к которой мы, смертные, можем обратиться при первой же необходимости. Google соответствует этому в значительно большей степени, чем традиционные «боги» .

Доказательство №9

Google существует, и этому есть великое множество свидетельств. Их значительно больше, чем у любого другого сегодняшнего «бога» . Экстраординарные заявления требуют экстраординарных фактов. Так узри же их своими глазами: приди к Google и познай всю безграничную мощь Google. От тебя не требуется ни капли веры.

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

Нативное gzip-сжатие в JS

1 июля 2025 года, 12:46

Я недавно закрыл тикет на гитхабе, который висел с 2017 года. Его автор обращал внимание на длинные адреса картинок в моем сервисе математических формул. С 2023 года нативное сжатие произвольных данных в JS стало доступным во всех основных браузерах, и с его помощью я сделал вариант сжатых адресов.

Давние читатели вспомнят, что я уже рассказывал об адресах картинок, и даже упоминал об этом тикете. Повторю, что для использования в вебе формулы, например, $$a^2+b^2=c^2$$, её исходник на латехе a^2+b^2=c^2 кодируется через проценты (RFC 3986) и подставляется в URL:

//i.upmath.me/svg/a%5E2%2Bb%5E2%3Dc%5E2

Кодирование через символы процента очень неэкономное, поэтому и без того длинный код изображений и диаграмм становится ещё больше. Адрес $$a^2+b^2=c^2$$ в новой схеме выглядит так:

//i.upmath.me/svgb/S4wz0k6KM7JNjjMCAA

Здесь вместо кодирования через проценты используется сжатие deflate (тот же алгоритм, что и в gzip) и кодировка, аналогичная base64. Вот рабочий пример кода, который делает такое преобразование:

function deflateRaw(text, callback) {
    if (typeof CompressionStream === 'undefined') {
        callback(null);
        return;
    }

    try {
        var stream = new Blob([text]).stream();
        var compressedStream = stream.pipeThrough(new CompressionStream('deflate-raw'));

        new Response(compressedStream).blob().then(function (compressedBlob) {
            return compressedBlob.arrayBuffer();
        }).then(function (buffer) {
            var compressedArray = new Uint8Array(buffer);
            var binary = Array.from(compressedArray).map(function (b) {
                return String.fromCharCode(b);
            }).join('');
            var base64 = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
            callback(base64);
        }).catch(function () {
            callback(null);
        });
    } catch (e) {
        callback(null);
    }
}

function getImgPath(formula, callback) {
    var fallbackUrl = '//i.upmath.me/svg/' + encodeURIComponent(formula);

    deflateRaw(formula, function (compressed) {
        var shortUrl = compressed ? '//i.upmath.me/svgb/' + compressed : null;
        callback(shortUrl && shortUrl.length < fallbackUrl.length ? shortUrl : fallbackUrl);
    });
}

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

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

var node = document.createElement('img');
getImgPath('a^2+b^2=c^2', function(path) {
    node.setAttribute('src', path);
});

Стоит отметить, что сам алгоритм сжатия deflate был давно портирован на JS, поэтому при необходимости можно было использовать сторонние библиотеки, например, pako. Кроме того, код библиотек работает синхронно, так что ни о каких коллбэках и промисах думать не нужно. В моём же случае я не хотел в мини-скрипт по конвертации формул в картинки добавлять реализацию алгоритма сжатия на десяток-другой килобайт, тем более оставался обходной путь для старых браузеров с несжатыми адресами.

Оценим результат на примере диаграммы из предыдущей заметки. Длина старого несжатого URL равна 6,3 килобайт, а сжатого — 1,3 килобайт, что почти в 5 раз короче.

    2 комментария

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

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

29 мая 2025 года, 12:35

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

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

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

В этой задаче кажущийся ответ — три оборота — неправильный. Оборотов будет на 1 больше, чем отношение радиусов. В этом можно убедиться напрямую, просто подсчитав количество оборотов. Я сомневаюсь, что кто-то из читателей будет вырезать круги из картона и обкатывать один круг другим, поэтому давайте смотреть на анимацию. Здесь окружности заменены на «шестеренки», чтобы отсутствие проскальзывания было особенно наглядным:

$$\dvisvgm\definecolor{cyan}{RGB}{0, 200, 250} \shorthandoff{"} \usetikzlibrary {shapes.geometric} \usetikzlibrary{animations} \begin{tikzpicture} \def\a{1} \def\b{3} \useasboundingbox (-\b-2*\a-0.1,-\b-2*\a-0.1) rectangle (\b+2*\a+0.1,\b+2*\a+0.1); \draw[cyan,very thin] (-\b-2*\a,-\b-2*\a) grid (\b+2*\a,\b+2*\a); \node[star,star points=57, star point ratio=1.07,minimum size=6.2cm, draw,fill=white] at (0,0); \draw[purple,fill] (0:\b) circle (1pt) -- (0,0) circle (1pt) node [midway, sloped, above] {$\b$} ; \begin{scope}:rotate = {0s="0", (5*\b)s="360",repeats} \begin{scope} :rotate = {0s="0", (5*\a)s="360", origin={(\b+\a,0)}, repeats} \node [star,star points=19, star point ratio=1.2,minimum size=2.2cm, draw,fill=white] at (0:\b+\a) {}; \draw [purple,fill] (0:\b+\a) circle (1pt) -- (0:\b) circle (1pt) node [midway, sloped, above] {$\a$} ; \end{scope} \end{scope} \coordinate (A) at (1,1.5); \node [fill=white,inner sep=1pt,anchor=east,xshift=3pt,yshift=-1pt] at (A) {$\text{обороты: }\,\,\,.$}; \foreach \t in {3,2,...,0} { \node :opacity = { 3.75*(0) s="0", 3.75*(0+\t) s="0", 3.75*(0.001+\t) s="1", 3.75*(0.999+\t) s="1", 3.75*(1+\t)s="0", 3.75*(4) s="0", repeats } [anchor=east,inner sep=1pt] at (A) {$\t$}; } \foreach \t in {0,1,...,9} { \node :opacity = { 0.375*(0) s="0", 0.375*(0+\t) s="0", 0.375*(0.01+\t) s="1", 0.375*(0.99+\t) s="1", 0.375*(1+\t)s="0", 0.375*(10) s="0", repeats } [anchor=west,inner sep=1.5pt] at (A) {$\t$}; } \end{tikzpicture}$$

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

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

$$\dvisvgm\definecolor{cyan}{RGB}{0, 200, 250} \usetikzlibrary {shapes.geometric} \usetikzlibrary{animations} \begin{tikzpicture} \def\a{1} \def\b{3} \useasboundingbox (-\b-2*\a-0.1,-\b-2*\a-0.1) rectangle (\b+2*\a+0.1,\b+2*\a+0.1); \draw[cyan,very thin] (-\b-2*\a,-\b-2*\a) grid (\b+2*\a,\b+2*\a); \begin{scope}:rotate = {0s="0", 5s="-360", 6s="-360", 11s="0", 12s="0", repeats} \node[star,star points=57, star point ratio=1.07,minimum size=6.2cm, draw,fill=white] at (0,0); \draw[purple,fill] (0:\b) circle (1pt) -- (0,0) circle (1pt) node [midway, sloped, above] {$\b$} ; \end{scope} \begin{scope} :rotate = {0s="0", 6s="0", 11s="360", 12s="360", origin={(0,0)}, repeats} \begin{scope} :rotate = {0s="0", 5s="1080", 6s="1080", 11s="1080", 12s="1080", origin={(\b+\a,0)}, repeats} \node [star,star points=19, star point ratio=1.2,minimum size=2.2cm, draw,fill=white] at (0:\b+\a) {}; \draw [purple,fill] (0:\b+\a) circle (1pt) -- (0:\b) circle (1pt) node [midway, sloped, above] {$\a$} ; \end{scope} \end{scope} \end{tikzpicture}$$

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

туда →

Поделиться
Записи

Подписка на RSS (?)