raise editorial quality gate and revise 2018 spring
Build and deploy / deploy (push) Successful in 15s
@@ -312,4 +312,13 @@ h3 {
|
||||
.article-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Документация Bitrix и имена API часто содержат длинные неразрывные токены. */
|
||||
.article-hero h1,
|
||||
.article-content h2,
|
||||
.article-content h3,
|
||||
.article-content :not(pre) > code,
|
||||
.article-content a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { revisions as march2018Revisions } from '../scripts/upgrade-2018-03.mjs';
|
||||
import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs';
|
||||
|
||||
// This layer replaces archived source entries without losing their stable slug and date.
|
||||
export const editorialRevisions = [
|
||||
...march2018Revisions,
|
||||
...may2018Revisions,
|
||||
];
|
||||
@@ -1,11 +1,21 @@
|
||||
import articles from '../data/articles.json';
|
||||
import { editorialRevisions } from '../data/editorial-revisions.mjs';
|
||||
|
||||
const revisionBySlug = new Map(
|
||||
editorialRevisions.map((revision) => [revision.slug, revision]),
|
||||
);
|
||||
|
||||
const publishedArticles = articles.map((article) => ({
|
||||
...article,
|
||||
...revisionBySlug.get(article.slug),
|
||||
}));
|
||||
|
||||
export function getArticles() {
|
||||
return articles;
|
||||
return publishedArticles;
|
||||
}
|
||||
|
||||
export function getArticleBySlug(slug) {
|
||||
return articles.find((article) => article.slug === slug);
|
||||
return publishedArticles.find((article) => article.slug === slug);
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Построение символьного кода элемента Bitrix</title>
|
||||
<desc id="desc">Схема показывает путь от названия элемента через транслитерацию и проверку занятости кода к сохранению элемента или добавлению суффикса.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#264653"/>
|
||||
</marker>
|
||||
<style>
|
||||
.bg { fill: #f7f4ec; }
|
||||
.node { fill: #ffffff; stroke: #264653; stroke-width: 3; }
|
||||
.accent { fill: #e9c46a; stroke: #264653; stroke-width: 3; }
|
||||
.success { fill: #a8dadc; stroke: #264653; stroke-width: 3; }
|
||||
.warn { fill: #f4a261; stroke: #264653; stroke-width: 3; }
|
||||
.line { fill: none; stroke: #264653; stroke-width: 3; marker-end: url(#arrow); }
|
||||
.dash { fill: none; stroke: #264653; stroke-width: 3; stroke-dasharray: 10 8; marker-end: url(#arrow); }
|
||||
.label { font: 700 25px Arial, sans-serif; fill: #1d3557; }
|
||||
.text { font: 20px Arial, sans-serif; fill: #264653; }
|
||||
.code { font: 700 19px "Courier New", monospace; fill: #1d3557; }
|
||||
.small { font: 17px Arial, sans-serif; fill: #457b9d; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="bg" width="1200" height="700" rx="28"/>
|
||||
<text x="60" y="68" class="label">Символьный код — это цепочка проверок, а не только транслит</text>
|
||||
|
||||
<rect x="58" y="165" width="190" height="155" rx="18" class="node"/>
|
||||
<text x="83" y="214" class="label">1. Имя</text>
|
||||
<text x="83" y="252" class="text">«Кофе Classic</text>
|
||||
<text x="83" y="280" class="text">250 г»</text>
|
||||
|
||||
<path d="M248 243 H337" class="line"/>
|
||||
|
||||
<rect x="347" y="150" width="220" height="185" rx="18" class="accent"/>
|
||||
<text x="373" y="199" class="label">2. CUtil::</text>
|
||||
<text x="373" y="230" class="label">translit</text>
|
||||
<text x="373" y="270" class="small">нижний регистр,</text>
|
||||
<text x="373" y="296" class="small">дефис вместо пробела</text>
|
||||
|
||||
<path d="M567 243 H658" class="line"/>
|
||||
|
||||
<rect x="668" y="165" width="210" height="155" rx="18" class="node"/>
|
||||
<text x="695" y="214" class="label">3. Кандидат</text>
|
||||
<text x="695" y="257" style="font: 700 16px "Courier New", monospace; fill: #1d3557;">kofe-classic-250-g</text>
|
||||
|
||||
<path d="M878 243 H962" class="line"/>
|
||||
|
||||
<rect x="972" y="150" width="180" height="185" rx="18" class="node"/>
|
||||
<text x="997" y="198" class="label">4. GetList</text>
|
||||
<text x="997" y="233" class="small">IBLOCK_ID</text>
|
||||
<text x="997" y="260" class="small">+ CODE</text>
|
||||
<text x="997" y="291" class="small">есть запись?</text>
|
||||
|
||||
<path d="M1062 335 V438 H932" class="line"/>
|
||||
<rect x="722" y="408" width="200" height="125" rx="18" class="success"/>
|
||||
<text x="748" y="457" class="label">Свободен</text>
|
||||
<text x="748" y="493" class="text">передаём в Add</text>
|
||||
|
||||
<path d="M972 243 H930 V580 H710" class="dash"/>
|
||||
<rect x="460" y="534" width="240" height="120" rx="18" class="warn"/>
|
||||
<text x="487" y="580" class="label">Занят</text>
|
||||
<text x="487" y="616" class="text">добавляем -2, -3…</text>
|
||||
<path d="M460 594 H345 V380 H770 V320" class="dash"/>
|
||||
|
||||
<text x="938" y="412" class="small">нет</text>
|
||||
<text x="884" y="566" class="small">да</text>
|
||||
<text x="58" y="676" class="small">Проверка по списку полезна для последовательного ввода. При параллельном импорте нужен отдельный проектный способ исключить гонку.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Диагностика конфликта символьного кода Bitrix</title>
|
||||
<desc id="desc">Дерево проверки для ситуации, когда адрес карточки открывает не тот элемент: путь, переменные, выборка и набор совпадений.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#3d405b"/>
|
||||
</marker>
|
||||
<style>
|
||||
.bg { fill: #fff8ed; }
|
||||
.node { fill: #ffffff; stroke: #3d405b; stroke-width: 3; }
|
||||
.focus { fill: #d8e7ff; stroke: #3d405b; stroke-width: 3; }
|
||||
.ok { fill: #cce8d4; stroke: #3d405b; stroke-width: 3; }
|
||||
.warn { fill: #f8d49c; stroke: #3d405b; stroke-width: 3; }
|
||||
.bad { fill: #f6b3a8; stroke: #3d405b; stroke-width: 3; }
|
||||
.line { fill: none; stroke: #3d405b; stroke-width: 3; marker-end: url(#arrow); }
|
||||
.label { font: 700 25px Arial, sans-serif; fill: #3d405b; }
|
||||
.text { font: 20px Arial, sans-serif; fill: #3d405b; }
|
||||
.code { font: 700 18px "Courier New", monospace; fill: #3d405b; }
|
||||
.small { font: 17px Arial, sans-serif; fill: #5c677d; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="bg" width="1200" height="700" rx="28"/>
|
||||
<text x="56" y="67" class="label">Карточка открывает не тот товар: сначала считаем совпадения</text>
|
||||
|
||||
<rect x="462" y="108" width="278" height="112" rx="18" class="focus"/>
|
||||
<text x="491" y="155" class="label">Ожидаемый URL</text>
|
||||
<text x="491" y="190" class="code">.../classic-250-g/</text>
|
||||
|
||||
<path d="M601 220 V296" class="line"/>
|
||||
<rect x="403" y="306" width="396" height="116" rx="18" class="node"/>
|
||||
<text x="430" y="352" class="label">Что компонент получил?</text>
|
||||
<text x="430" y="389" class="code">ELEMENT_CODE = classic-250-g</text>
|
||||
|
||||
<path d="M601 422 V495" class="line"/>
|
||||
<rect x="360" y="425" width="480" height="92" rx="18" class="focus"/>
|
||||
<text x="390" y="466" class="label">GetList по этому CODE</text>
|
||||
<text x="390" y="495" class="small">в том же IBLOCK_ID и с теми же фильтрами</text>
|
||||
|
||||
<path d="M360 471 H288 V594 H294" class="line"/>
|
||||
<rect x="56" y="544" width="228" height="98" rx="18" class="warn"/>
|
||||
<text x="80" y="585" class="label">0 записей</text>
|
||||
<text x="80" y="613" class="small">шаблон, фильтр, активность</text>
|
||||
|
||||
<path d="M840 471 H912 V594 H906" class="line"/>
|
||||
<rect x="916" y="544" width="228" height="98" rx="18" class="bad"/>
|
||||
<text x="940" y="585" class="label">2+ записи</text>
|
||||
<text x="940" y="613" class="small">конфликт CODE или фильтр</text>
|
||||
|
||||
<path d="M600 517 V558" class="line"/>
|
||||
<rect x="438" y="558" width="324" height="84" rx="18" class="ok"/>
|
||||
<text x="469" y="610" style="font: 700 23px Arial, sans-serif; fill: #3d405b;">1 запись: сверяем ID</text>
|
||||
|
||||
<text x="56" y="676" class="small">Кеш проверяем после данных и маршрута. Иначе он становится удобным, но недоказанным объяснением.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,64 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Путь URL до элемента инфоблока в Bitrix</title>
|
||||
<desc id="desc">Диаграмма показывает как адрес страницы разбирается шаблоном SEF, превращается в переменные и используется для поиска элемента инфоблока.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
|
||||
<path d="M0,0 L12,6 L0,12 z" fill="#1d3557"/>
|
||||
</marker>
|
||||
<style>
|
||||
.bg { fill: #f5f7fb; }
|
||||
.node { fill: #ffffff; stroke: #1d3557; stroke-width: 3; }
|
||||
.route { fill: #cde8f0; stroke: #1d3557; stroke-width: 3; }
|
||||
.query { fill: #f7d488; stroke: #1d3557; stroke-width: 3; }
|
||||
.result { fill: #b9e3c6; stroke: #1d3557; stroke-width: 3; }
|
||||
.fail { fill: #f6b3a8; stroke: #1d3557; stroke-width: 3; }
|
||||
.line { fill: none; stroke: #1d3557; stroke-width: 3; marker-end: url(#arrow); }
|
||||
.label { font: 700 25px Arial, sans-serif; fill: #1d3557; }
|
||||
.text { font: 20px Arial, sans-serif; fill: #264653; }
|
||||
.code { font: 700 18px "Courier New", monospace; fill: #1d3557; }
|
||||
.small { font: 17px Arial, sans-serif; fill: #457b9d; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="bg" width="1200" height="700" rx="28"/>
|
||||
<text x="56" y="67" class="label">Адрес не ищет запись сам: компонент сначала восстанавливает переменные</text>
|
||||
|
||||
<rect x="56" y="160" width="238" height="154" rx="18" class="node"/>
|
||||
<text x="82" y="209" class="label">Запрос браузера</text>
|
||||
<text x="82" y="254" class="code">/catalog/kofe/</text>
|
||||
<text x="82" y="281" class="code">classic-250-g/</text>
|
||||
|
||||
<path d="M294 237 H380" class="line"/>
|
||||
|
||||
<rect x="390" y="135" width="256" height="205" rx="18" class="route"/>
|
||||
<text x="416" y="185" class="label">SEF-шаблон</text>
|
||||
<text x="416" y="227" class="code">#SECTION_CODE#/</text>
|
||||
<text x="416" y="255" class="code">#ELEMENT_CODE#/</text>
|
||||
<text x="416" y="301" class="small">ParseComponentPath</text>
|
||||
|
||||
<path d="M646 237 H731" class="line"/>
|
||||
|
||||
<rect x="741" y="135" width="240" height="205" rx="18" class="query"/>
|
||||
<text x="767" y="184" class="label">Переменные</text>
|
||||
<text x="767" y="225" class="code">SECTION_CODE</text>
|
||||
<text x="767" y="254" class="code">ELEMENT_CODE</text>
|
||||
<text x="767" y="301" class="small">не запись в БД</text>
|
||||
|
||||
<path d="M981 237 H1066" class="line"/>
|
||||
|
||||
<rect x="1076" y="160" width="84" height="154" rx="18" class="node"/>
|
||||
<text x="1093" y="209" class="label">Query</text>
|
||||
<text x="1092" y="253" class="small">CODE</text>
|
||||
<text x="1092" y="279" class="small">+ filter</text>
|
||||
|
||||
<path d="M1118 314 V431 H941" class="line"/>
|
||||
<rect x="692" y="402" width="239" height="133" rx="18" class="result"/>
|
||||
<text x="720" y="452" class="label">Элемент найден</text>
|
||||
<text x="720" y="490" class="text">детальная страница</text>
|
||||
|
||||
<path d="M1076 239 H1018 V592 H839" class="line"/>
|
||||
<rect x="600" y="564" width="229" height="95" rx="18" class="fail"/>
|
||||
<text x="625" y="606" class="label">Нет совпадения</text>
|
||||
<text x="625" y="635" class="text">404 или другой путь</text>
|
||||
|
||||
<text x="56" y="676" class="small">Если шаблон, имя переменной и фильтр расходятся, менять CODE бесполезно: сначала надо увидеть, какое значение восстановлено из URL.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Три уровня результата cURL-интеграции</title>
|
||||
<desc id="desc">После curl_exec сначала проверяется false и ошибка транспорта, затем код HTTP, а затем контракт тела ответа.</desc>
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1f2437"/>
|
||||
<stop offset="1" stop-color="#3b315f"/>
|
||||
</linearGradient>
|
||||
<marker id="flow-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
|
||||
<path d="M0 0 L10 5 L0 10z" fill="#f8e9ac"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="1200" height="620" rx="34" fill="url(#background)"/>
|
||||
<text x="74" y="88" fill="#fff9e4" font-family="Arial, sans-serif" font-size="32" font-weight="700">cURL отвечает за передачу, а не за успех операции</text>
|
||||
<text x="74" y="125" fill="#d1c5e6" font-family="Arial, sans-serif" font-size="19">Проверки идут слева направо: транспорт → HTTP → полезная нагрузка</text>
|
||||
<g font-family="Arial, sans-serif">
|
||||
<rect x="75" y="228" width="194" height="116" rx="18" fill="#f8e9ac"/>
|
||||
<text x="109" y="275" fill="#352f52" font-size="22" font-weight="700">curl_exec()</text>
|
||||
<text x="118" y="310" fill="#352f52" font-size="17">получить тело</text>
|
||||
<path d="M269 286 H385" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
|
||||
<rect x="405" y="228" width="200" height="116" rx="18" fill="#e4d7ff"/>
|
||||
<text x="438" y="274" fill="#352f52" font-size="22" font-weight="700">body === false?</text>
|
||||
<text x="450" y="310" fill="#352f52" font-size="17">cURL + сеть</text>
|
||||
<path d="M505 344 V439 H325" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
|
||||
<text x="422" y="405" fill="#f8e9ac" font-size="16">да</text>
|
||||
<rect x="112" y="438" width="214" height="104" rx="18" fill="#f2b8ae"/>
|
||||
<text x="145" y="481" fill="#5d2427" font-size="21" font-weight="700">transport error</text>
|
||||
<text x="151" y="514" fill="#5d2427" font-size="16">errno, error, time</text>
|
||||
<path d="M605 286 H695" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
|
||||
<text x="636" y="265" fill="#f8e9ac" font-size="16">нет</text>
|
||||
<rect x="715" y="228" width="182" height="116" rx="18" fill="#b8e8e0"/>
|
||||
<text x="745" y="274" fill="#21423f" font-size="22" font-weight="700">HTTP 2xx?</text>
|
||||
<text x="737" y="310" fill="#21423f" font-size="17">curl_getinfo</text>
|
||||
<path d="M806 344 V439 H965" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
|
||||
<text x="842" y="405" fill="#f8e9ac" font-size="16">нет</text>
|
||||
<rect x="966" y="438" width="170" height="104" rx="18" fill="#f2b8ae"/>
|
||||
<text x="1003" y="481" fill="#5d2427" font-size="21" font-weight="700">HTTP error</text>
|
||||
<text x="992" y="514" fill="#5d2427" font-size="16">status + route</text>
|
||||
<path d="M897 286 H995" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
|
||||
<text x="927" y="265" fill="#f8e9ac" font-size="16">да</text>
|
||||
<rect x="1015" y="228" width="121" height="116" rx="18" fill="#d4e8ff"/>
|
||||
<text x="1045" y="274" fill="#294465" font-size="21" font-weight="700">Тело</text>
|
||||
<text x="1032" y="310" fill="#294465" font-size="16">контракт</text>
|
||||
</g>
|
||||
<text x="75" y="585" fill="#d1c5e6" font-family="Arial, sans-serif" font-size="18">Статус 404 или 500 может прийти как строка: это не false для curl_exec().</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,73 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Контракт Ajax-формы в legacy jQuery</title>
|
||||
<desc id="desc">Диаграмма показывает состояния формы: готова, запрос отправлен, успешный или ошибочный ответ, затем обязательное освобождение интерфейса в обработчике always.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.bg { fill: #fffdfa; }
|
||||
.title { fill: #1e252d; font: 700 39px Arial, sans-serif; }
|
||||
.sub { fill: #64707d; font: 25px Arial, sans-serif; }
|
||||
.state { stroke-width: 3; }
|
||||
.idle { fill: #eef3fa; stroke: #355c9a; }
|
||||
.request { fill: #fff4e8; stroke: #b4432a; }
|
||||
.success { fill: #e5f2ed; stroke: #216869; }
|
||||
.failure { fill: #f9eceb; stroke: #a43228; }
|
||||
.final { fill: #f3f6f9; stroke: #64707d; }
|
||||
.state-title { fill: #1e252d; font: 700 30px Arial, sans-serif; text-anchor: middle; }
|
||||
.state-copy { fill: #44515d; font: 23px Arial, sans-serif; text-anchor: middle; }
|
||||
.code { fill: #18385d; font: 700 18px "Courier New", monospace; text-anchor: middle; }
|
||||
.arrow { fill: none; stroke: #64707d; stroke-width: 6; marker-end: url(#arrow); }
|
||||
.safe { fill: #65492e; font: 700 26px Arial, sans-serif; text-anchor: middle; }
|
||||
</style>
|
||||
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
|
||||
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" width="1200" height="900"/>
|
||||
<text class="title" x="65" y="72">Ajax-форма: интерфейс возвращается</text>
|
||||
<text class="title" x="65" y="115">в готовое состояние</text>
|
||||
<text class="sub" x="65" y="158">Клиентский флаг убирает повторный submit в текущем DOM, а always освобождает кнопку.</text>
|
||||
|
||||
<rect class="state idle" x="75" y="235" width="235" height="150" rx="25"/>
|
||||
<text class="state-title" x="192" y="286">1. Готова</text>
|
||||
<text class="state-copy" x="192" y="327">Кнопка доступна,</text>
|
||||
<text class="state-copy" x="192" y="358">запроса нет.</text>
|
||||
|
||||
<path class="arrow" d="M310 310 H410"/>
|
||||
|
||||
<rect class="state request" x="410" y="215" width="380" height="190" rx="25"/>
|
||||
<text class="state-title" x="600" y="267">2. submit</text>
|
||||
<text class="code" x="600" y="302">data('request')</text>
|
||||
<text class="code" x="600" y="329">+ prop('disabled')</text>
|
||||
<text class="state-copy" x="600" y="367">Один jqXHR хранится на форме.</text>
|
||||
<text class="state-copy" x="600" y="393">Повторный submit выходит сразу.</text>
|
||||
|
||||
<path class="arrow" d="M790 310 H885"/>
|
||||
|
||||
<rect class="state final" x="885" y="235" width="240" height="150" rx="25"/>
|
||||
<text class="state-title" x="1005" y="286">3. jqXHR</text>
|
||||
<text class="state-copy" x="1005" y="327">Серверный ответ</text>
|
||||
<text class="state-copy" x="1005" y="358">или ошибка сети.</text>
|
||||
|
||||
<path class="arrow" d="M1005 385 V500 H795"/>
|
||||
<path class="arrow" d="M1005 385 V500 H405"/>
|
||||
|
||||
<rect class="state success" x="625" y="535" width="340" height="155" rx="25"/>
|
||||
<text class="state-title" x="795" y="584">4a. done</text>
|
||||
<text class="state-copy" x="795" y="625">Показать подтверждённый</text>
|
||||
<text class="state-copy" x="795" y="656">результат сервера.</text>
|
||||
|
||||
<rect class="state failure" x="235" y="535" width="340" height="155" rx="25"/>
|
||||
<text class="state-title" x="405" y="584">4b. fail</text>
|
||||
<text class="state-copy" x="405" y="625">Показать ошибку и не</text>
|
||||
<text class="state-copy" x="405" y="656">считать отправку успехом.</text>
|
||||
|
||||
<path class="arrow" d="M405 690 V758 H600"/>
|
||||
<path class="arrow" d="M795 690 V758 H600"/>
|
||||
|
||||
<rect class="state final" x="415" y="780" width="370" height="75" rx="20"/>
|
||||
<text class="code" x="600" y="816">always: removeData</text>
|
||||
<text class="code" x="600" y="841">+ disabled(false)</text>
|
||||
|
||||
<text class="safe" x="600" y="885">Ограничение: клиент не заменяет серверную защиту от повторной операции.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,69 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 890" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Прямая и делегированная привязка событий после замены HTML</title>
|
||||
<desc id="desc">Сравнение двух подходов: прямой обработчик находится на кнопке и исчезает при замене содержимого контейнера, делегированный обработчик остаётся на постоянном контейнере и получает события от новой кнопки.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.bg { fill: #fffdfa; }
|
||||
.title { fill: #1e252d; font: 700 40px Arial, sans-serif; }
|
||||
.sub { fill: #64707d; font: 25px Arial, sans-serif; }
|
||||
.panel { fill: #ffffff; stroke-width: 3; }
|
||||
.bad { stroke: #b4432a; }
|
||||
.good { stroke: #216869; }
|
||||
.panel-title { fill: #1e252d; font: 700 31px Arial, sans-serif; text-anchor: middle; }
|
||||
.copy { fill: #44515d; font: 23px Arial, sans-serif; text-anchor: middle; }
|
||||
.code { fill: #18385d; font: 700 17px "Courier New", monospace; text-anchor: middle; }
|
||||
.root { fill: #eef3fa; stroke: #355c9a; stroke-width: 3; }
|
||||
.old { fill: #fff4e8; stroke: #b4432a; stroke-width: 3; }
|
||||
.new { fill: #e5f2ed; stroke: #216869; stroke-width: 3; }
|
||||
.handler { fill: #f3f6f9; stroke: #64707d; stroke-width: 2; }
|
||||
.cross { stroke: #b4432a; stroke-width: 7; stroke-linecap: round; }
|
||||
.arrow { fill: none; stroke: #64707d; stroke-width: 5; marker-end: url(#arrow); }
|
||||
.bubble { fill: none; stroke: #216869; stroke-width: 5; stroke-dasharray: 12 10; marker-end: url(#arrow-green); }
|
||||
.note { fill: #65492e; font: 700 26px Arial, sans-serif; text-anchor: middle; }
|
||||
</style>
|
||||
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
|
||||
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
|
||||
<path d="M0,0 L14,7 L0,14 z" fill="#216869"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" width="1200" height="890"/>
|
||||
<text class="title" x="65" y="82">После container.html(): что остаётся?</text>
|
||||
<text class="sub" x="65" y="126">Событие переживает замену DOM только тогда, когда привязано к постоянному предку.</text>
|
||||
|
||||
<rect class="panel bad" x="60" y="190" width="510" height="570" rx="28"/>
|
||||
<text class="panel-title" x="315" y="245">Прямая привязка к кнопке</text>
|
||||
<text class="code" x="315" y="282">$('.js-remove')</text>
|
||||
<text class="code" x="315" y="310">.on('click', handler)</text>
|
||||
|
||||
<rect class="root" x="125" y="335" width="380" height="170" rx="20"/>
|
||||
<text class="copy" x="315" y="378">#cart получает новый HTML</text>
|
||||
<rect class="old" x="210" y="414" width="210" height="60" rx="12"/>
|
||||
<text class="copy" x="315" y="452">старая кнопка</text>
|
||||
<rect class="handler" x="160" y="530" width="310" height="70" rx="15"/>
|
||||
<text class="copy" x="315" y="563">обработчик жил</text>
|
||||
<text class="copy" x="315" y="589">на дочернем узле</text>
|
||||
<path class="arrow" d="M315 505 V526"/>
|
||||
<path class="cross" d="M256 636 L374 704 M374 636 L256 704"/>
|
||||
<text class="copy" x="315" y="735">Новая кнопка создана без него.</text>
|
||||
|
||||
<rect class="panel good" x="630" y="190" width="510" height="570" rx="28"/>
|
||||
<text class="panel-title" x="885" y="245">Делегирование от #cart</text>
|
||||
<text class="code" x="885" y="282">$('#cart').on('click.cart',</text>
|
||||
<text class="code" x="885" y="310">'.js-remove', handler)</text>
|
||||
|
||||
<rect class="root" x="695" y="335" width="380" height="235" rx="20"/>
|
||||
<text class="copy" x="885" y="378">#cart остаётся в DOM</text>
|
||||
<rect class="handler" x="748" y="402" width="274" height="58" rx="14"/>
|
||||
<text class="copy" x="885" y="439">обработчик на контейнере</text>
|
||||
<rect class="new" x="780" y="487" width="210" height="60" rx="12"/>
|
||||
<text class="copy" x="885" y="525">новая кнопка</text>
|
||||
<path class="bubble" d="M885 487 V463"/>
|
||||
<text class="copy" x="885" y="620">Клик всплывает до #cart.</text>
|
||||
<text class="copy" x="885" y="655">Перепривязывать кнопку не нужно.</text>
|
||||
|
||||
<rect x="160" y="800" width="880" height="54" rx="16" fill="#f3f6f9" stroke="#64707d" stroke-width="2"/>
|
||||
<text class="note" x="600" y="836">Корень — ближайший постоянный контейнер.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 790" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Повторная инициализация jQuery-виджета без дублирования обработчиков</title>
|
||||
<desc id="desc">Схема показывает, как повторный вызов функции инициализации снимает только свои обработчики через пространство имён и затем назначает один новый обработчик.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.bg { fill: #fffdfa; }
|
||||
.title { fill: #1e252d; font: 700 42px Arial, sans-serif; }
|
||||
.sub { fill: #64707d; font: 26px Arial, sans-serif; }
|
||||
.card { stroke-width: 3; }
|
||||
.warm { fill: #fff4e8; stroke: #b4432a; }
|
||||
.blue { fill: #eef3fa; stroke: #355c9a; }
|
||||
.green { fill: #e5f2ed; stroke: #216869; }
|
||||
.card-title { fill: #1e252d; font: 700 30px Arial, sans-serif; text-anchor: middle; }
|
||||
.card-copy { fill: #44515d; font: 24px Arial, sans-serif; text-anchor: middle; }
|
||||
.code { fill: #18385d; font: 700 21px "Courier New", monospace; text-anchor: middle; }
|
||||
.note { fill: #65492e; font: 700 28px Arial, sans-serif; text-anchor: middle; }
|
||||
.small { fill: #44515d; font: 23px Arial, sans-serif; text-anchor: middle; }
|
||||
.arrow { fill: none; stroke: #64707d; stroke-width: 6; marker-end: url(#arrow); }
|
||||
</style>
|
||||
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
|
||||
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect class="bg" width="1200" height="790"/>
|
||||
<text class="title" x="70" y="78">Повторный mount: один обработчик</text>
|
||||
<text class="title" x="70" y="126">при любом числе вызовов</text>
|
||||
<text class="sub" x="70" y="170">Пространство имён отделяет события виджета от остального legacy-кода.</text>
|
||||
|
||||
<rect class="card warm" x="70" y="245" width="300" height="235" rx="28"/>
|
||||
<text class="card-title" x="220" y="300">1. mount(root)</text>
|
||||
<text class="card-copy" x="220" y="345">Виджет вызывают</text>
|
||||
<text class="card-copy" x="220" y="380">после Ajax, таба</text>
|
||||
<text class="card-copy" x="220" y="415">или повторного рендера.</text>
|
||||
|
||||
<path class="arrow" d="M370 362 H455"/>
|
||||
|
||||
<rect class="card blue" x="455" y="245" width="300" height="235" rx="28"/>
|
||||
<text class="card-title" x="605" y="300">2. Снять только свои</text>
|
||||
<text class="code" x="605" y="360">.off('.orderForm')</text>
|
||||
<text class="card-copy" x="605" y="410">Соседние click-события</text>
|
||||
<text class="card-copy" x="605" y="443">не трогаем.</text>
|
||||
|
||||
<path class="arrow" d="M755 362 H840"/>
|
||||
|
||||
<rect class="card green" x="840" y="245" width="290" height="235" rx="28"/>
|
||||
<text class="card-title" x="985" y="300">3. Назначить один</text>
|
||||
<text class="code" x="985" y="360">.on('click.orderForm')</text>
|
||||
<text class="card-copy" x="985" y="410">Новый обработчик</text>
|
||||
<text class="card-copy" x="985" y="443">предсказуемо один.</text>
|
||||
|
||||
<path class="arrow" d="M600 520 V600"/>
|
||||
<rect x="180" y="620" width="840" height="90" rx="22" fill="#f3f6f9" stroke="#64707d" stroke-width="2"/>
|
||||
<text class="note" x="600" y="660">Проверка: три вызова mount() → одна отправка формы.</text>
|
||||
<text class="small" x="600" y="694">Это свойство функции инициализации, а не удача порядка загрузки.</text>
|
||||
|
||||
<text class="small" x="600" y="755">Схема для статьи о jQuery 3.x и legacy-интерфейсе, май 2018.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Диагностика JSON-ответа API</title>
|
||||
<desc id="desc">Сырой ответ разбирается функцией json_decode, затем проверяется json_last_error, а при успехе — тип и обязательные поля контракта.</desc>
|
||||
<defs>
|
||||
<linearGradient id="json-bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#173a46"/>
|
||||
<stop offset="1" stop-color="#245f55"/>
|
||||
</linearGradient>
|
||||
<marker id="json-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
|
||||
<path d="M0 0 L10 5 L0 10z" fill="#f2d38d"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="1200" height="620" rx="34" fill="url(#json-bg)"/>
|
||||
<text x="72" y="86" fill="#fcf7e8" font-family="Arial, sans-serif" font-size="32" font-weight="700">«null» и ошибка синтаксиса — не один случай</text>
|
||||
<text x="72" y="124" fill="#c2dfd2" font-family="Arial, sans-serif" font-size="19">Сначала проверяем корректность JSON, потом — договор конкретного endpoint</text>
|
||||
<g font-family="Arial, sans-serif">
|
||||
<rect x="68" y="242" width="208" height="122" rx="19" fill="#d4e8ff"/>
|
||||
<text x="112" y="289" fill="#244868" font-size="23" font-weight="700">Сырой ответ</text>
|
||||
<text x="102" y="323" fill="#244868" font-size="17">строка + размер</text>
|
||||
<path d="M276 303 H386" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
|
||||
<rect x="407" y="242" width="202" height="122" rx="19" fill="#f2d38d"/>
|
||||
<text x="440" y="289" fill="#4a3b21" font-size="23" font-weight="700">json_decode</text>
|
||||
<text x="446" y="323" fill="#4a3b21" font-size="17">без догадок</text>
|
||||
<path d="M609 303 H716" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
|
||||
<rect x="737" y="242" width="192" height="122" rx="19" fill="#b8e8e0"/>
|
||||
<text x="771" y="289" fill="#204944" font-size="22" font-weight="700">json_last_error</text>
|
||||
<text x="776" y="323" fill="#204944" font-size="17">JSON_ERROR_NONE?</text>
|
||||
<path d="M833 364 V452 H609" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
|
||||
<text x="780" y="414" fill="#f2d38d" font-size="16">нет</text>
|
||||
<rect x="408" y="449" width="204" height="104" rx="19" fill="#f5c5bb"/>
|
||||
<text x="438" y="491" fill="#61332d" font-size="21" font-weight="700">decode error</text>
|
||||
<text x="431" y="523" fill="#61332d" font-size="16">код + хеш тела</text>
|
||||
<path d="M929 303 H1000" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
|
||||
<text x="948" y="281" fill="#f2d38d" font-size="16">да</text>
|
||||
<rect x="1021" y="242" width="125" height="122" rx="19" fill="#e2d5ff"/>
|
||||
<text x="1047" y="286" fill="#4c376c" font-size="20" font-weight="700">Контракт</text>
|
||||
<text x="1036" y="320" fill="#4c376c" font-size="16">тип и поля</text>
|
||||
<path d="M1083 364 V452 H1000" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
|
||||
<rect x="797" y="449" width="204" height="104" rx="19" fill="#f5c5bb"/>
|
||||
<text x="823" y="491" fill="#61332d" font-size="21" font-weight="700">contract error</text>
|
||||
<text x="830" y="523" fill="#61332d" font-size="16">валидный JSON</text>
|
||||
</g>
|
||||
<text x="72" y="584" fill="#c2dfd2" font-family="Arial, sans-serif" font-size="18">Не пишем тело в общий журнал: размер и SHA-256 достаточно сравнить два ответа.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Диагностика PHP-ошибки в интеграции</title>
|
||||
<desc id="desc">Контекст операции создаётся перед вызовом партнёра и соединяется с тремя ветками обработки: warning, непойманное исключение и фатальная ошибка при завершении PHP.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#101828"/>
|
||||
<stop offset="1" stop-color="#1d3d4f"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#06111a" flood-opacity=".32"/>
|
||||
</filter>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
|
||||
<path d="M0 0 L10 5 L0 10z" fill="#b8e8e0"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="1200" height="620" rx="34" fill="url(#bg)"/>
|
||||
<text x="72" y="88" fill="#eaf5f4" font-family="Arial, sans-serif" font-size="32" font-weight="700">Контекст не должен появляться после падения</text>
|
||||
<text x="72" y="125" fill="#a9c8c4" font-family="Arial, sans-serif" font-size="19">request ID и этап операции создаются до внешнего вызова</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="72" y="215" width="220" height="150" rx="20" fill="#f0b667"/>
|
||||
<text x="104" y="267" fill="#192538" font-family="Arial, sans-serif" font-size="22" font-weight="700">Операция</text>
|
||||
<text x="104" y="302" fill="#192538" font-family="Arial, sans-serif" font-size="18">request_id</text>
|
||||
<text x="104" y="329" fill="#192538" font-family="Arial, sans-serif" font-size="18">stage</text>
|
||||
</g>
|
||||
<path d="M292 290 H395" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<circle cx="418" cy="290" r="15" fill="#b8e8e0"/>
|
||||
<path d="M433 290 H486 V193 H558" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M433 290 H486 V291 H558" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M433 290 H486 V389 H558" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="578" y="137" width="255" height="112" rx="18" fill="#d4e8ff"/>
|
||||
<text x="608" y="180" fill="#183653" font-family="Arial, sans-serif" font-size="21" font-weight="700">warning / notice</text>
|
||||
<text x="608" y="214" fill="#183653" font-family="Arial, sans-serif" font-size="17">set_error_handler</text>
|
||||
<rect x="578" y="235" width="255" height="112" rx="18" fill="#d8f1e5"/>
|
||||
<text x="608" y="278" fill="#174133" font-family="Arial, sans-serif" font-size="21" font-weight="700">Throwable</text>
|
||||
<text x="608" y="312" fill="#174133" font-family="Arial, sans-serif" font-size="17">set_exception_handler</text>
|
||||
<rect x="578" y="333" width="255" height="112" rx="18" fill="#f6d9d2"/>
|
||||
<text x="608" y="376" fill="#63342b" font-family="Arial, sans-serif" font-size="21" font-weight="700">fatal error</text>
|
||||
<text x="608" y="410" fill="#63342b" font-family="Arial, sans-serif" font-size="17">shutdown + error_get_last</text>
|
||||
</g>
|
||||
<path d="M833 193 H930 V291 H994" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M833 291 H994" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<path d="M833 389 H930 V291 H994" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="1014" y="215" width="140" height="150" rx="20" fill="#b8e8e0"/>
|
||||
<text x="1044" y="273" fill="#183b3a" font-family="Arial, sans-serif" font-size="22" font-weight="700">Лог</text>
|
||||
<text x="1037" y="307" fill="#183b3a" font-family="Arial, sans-serif" font-size="16">один факт</text>
|
||||
<text x="1034" y="333" fill="#183b3a" font-family="Arial, sans-serif" font-size="16">для разбора</text>
|
||||
</g>
|
||||
<text x="72" y="531" fill="#a9c8c4" font-family="Arial, sans-serif" font-size="18">Не пишем: пароли, токены, полный запрос и полный ответ партнёра.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,76 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Выдача приватного документа через PHP</title>
|
||||
<desc id="desc">Последовательность запроса: браузер, маршрут приложения, база документов, закрытое хранилище, HTTP-ответ.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f6f8fc"/>
|
||||
<stop offset="1" stop-color="#edf5f1"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#123049" flood-opacity=".11"/>
|
||||
</filter>
|
||||
<marker id="right-arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
|
||||
<path d="M 0 1 L 11 6 L 0 11 z" fill="#3f6b78"/>
|
||||
</marker>
|
||||
<marker id="left-arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
|
||||
<path d="M 0 1 L 11 6 L 0 11 z" fill="#6c8f9c"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 44px Inter, Arial, sans-serif; fill: #17324a; }
|
||||
.subtitle { font: 400 23px Inter, Arial, sans-serif; fill: #597080; }
|
||||
.actor { font: 700 22px Inter, Arial, sans-serif; fill: #17324a; text-anchor: middle; }
|
||||
.role { font: 600 16px Inter, Arial, sans-serif; fill: #6b7d8d; letter-spacing: 1.4px; text-anchor: middle; }
|
||||
.label { font: 600 18px Inter, Arial, sans-serif; fill: #34566a; }
|
||||
.code { font: 600 17px Menlo, Consolas, monospace; fill: #236779; }
|
||||
.note { font: 400 18px Inter, Arial, sans-serif; fill: #466174; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||
<text x="104" y="110" class="title">Приватный файл: доступ проверяется до чтения с диска</text>
|
||||
<text x="104" y="152" class="subtitle">URL содержит ID записи. Настоящий ключ и путь остаются за маршрутом приложения.</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="112" y="238" width="236" height="108" rx="22" fill="#ffffff"/>
|
||||
<text x="230" y="282" class="actor">Браузер</text>
|
||||
<text x="230" y="314" class="role">ПОЛЬЗОВАТЕЛЬ</text>
|
||||
|
||||
<rect x="466" y="238" width="250" height="108" rx="22" fill="#ffffff"/>
|
||||
<text x="591" y="282" class="actor">Маршрут PHP</text>
|
||||
<text x="591" y="314" class="role">АВТОРИЗАЦИЯ</text>
|
||||
|
||||
<rect x="837" y="238" width="248" height="108" rx="22" fill="#ffffff"/>
|
||||
<text x="961" y="282" class="actor">documents</text>
|
||||
<text x="961" y="314" class="role">БАЗА ДАННЫХ</text>
|
||||
|
||||
<rect x="1210" y="238" width="280" height="108" rx="22" fill="#ffffff"/>
|
||||
<text x="1350" y="282" class="actor">Закрытый каталог</text>
|
||||
<text x="1350" y="314" class="role">ФАЙЛОВАЯ СИСТЕМА</text>
|
||||
</g>
|
||||
|
||||
<line x1="230" y1="346" x2="230" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
|
||||
<line x1="591" y1="346" x2="591" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
|
||||
<line x1="961" y1="346" x2="961" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
|
||||
<line x1="1350" y1="346" x2="1350" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
|
||||
|
||||
<path d="M230 414 H580" fill="none" stroke="#3f6b78" stroke-width="6" stroke-linecap="round" marker-end="url(#right-arrow)"/>
|
||||
<text x="308" y="398" class="label">1. GET</text>
|
||||
<text x="308" y="430" class="code">/documents/42/download</text>
|
||||
|
||||
<path d="M591 490 H950" fill="none" stroke="#3f6b78" stroke-width="6" stroke-linecap="round" marker-end="url(#right-arrow)"/>
|
||||
<text x="684" y="474" class="label">2. Проверка владельца</text>
|
||||
<text x="684" y="506" class="code">id + owner_id + status</text>
|
||||
|
||||
<path d="M961 562 H1339" fill="none" stroke="#3f6b78" stroke-width="6" stroke-linecap="round" marker-end="url(#right-arrow)"/>
|
||||
<text x="1069" y="546" class="label">3. Найден ключ</text>
|
||||
<text x="1069" y="578" class="code">storage_key.pdf</text>
|
||||
|
||||
<path d="M1339 634 H602" fill="none" stroke="#6c8f9c" stroke-width="6" stroke-linecap="round" marker-end="url(#left-arrow)"/>
|
||||
<text x="1000" y="618" class="label">4. PHP читает только закрытый путь</text>
|
||||
|
||||
<path d="M580 706 H241" fill="none" stroke="#27846e" stroke-width="7" stroke-linecap="round" marker-end="url(#left-arrow)"/>
|
||||
<text x="314" y="689" class="label">5. HTTP-ответ</text>
|
||||
<text x="314" y="724" class="code">Content-Type: application/pdf</text>
|
||||
|
||||
<rect x="112" y="777" width="1378" height="66" rx="18" fill="#ffffff" stroke="#c9dcda" stroke-width="2"/>
|
||||
<text x="146" y="818" class="note">Прямого URL к каталогу нет: запись в базе связывает пользователя с ключом, а маршрут решает, можно ли читать файл.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,91 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Контракт безопасной загрузки аватара</title>
|
||||
<desc id="desc">Схема из четырёх шагов: браузер, временный файл PHP, проверка, закрытое хранилище.</desc>
|
||||
<defs>
|
||||
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f6f8fc"/>
|
||||
<stop offset="1" stop-color="#eef4f3"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#123049" flood-opacity=".12"/>
|
||||
</filter>
|
||||
<marker id="arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
|
||||
<path d="M 0 1 L 11 6 L 0 11 z" fill="#6b7d8d"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 44px Inter, Arial, sans-serif; fill: #17324a; }
|
||||
.subtitle { font: 400 23px Inter, Arial, sans-serif; fill: #597080; }
|
||||
.step { font: 700 18px Inter, Arial, sans-serif; fill: #75909f; letter-spacing: 1.8px; }
|
||||
.card-title { font: 700 28px Inter, Arial, sans-serif; fill: #17324a; }
|
||||
.card-text { font: 400 20px Inter, Arial, sans-serif; fill: #466174; }
|
||||
.code { font: 600 18px Menlo, Consolas, monospace; fill: #1f6672; }
|
||||
.badge { font: 700 16px Inter, Arial, sans-serif; fill: #2e5267; }
|
||||
.note { font: 600 18px Inter, Arial, sans-serif; fill: #36536a; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#background)"/>
|
||||
<path d="M0 684 C260 613 404 779 643 692 S1139 590 1600 717 V900 H0Z" fill="#e4f0ef"/>
|
||||
<text x="106" y="116" class="title">Загрузка аватара: решение принимается до переноса</text>
|
||||
<text x="106" y="158" class="subtitle">Клиент присылает файл. Приложение выбирает допустимый тип, размеры и собственный ключ.</text>
|
||||
|
||||
<line x1="390" y1="435" x2="481" y2="435" stroke="#6b7d8d" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<line x1="765" y1="435" x2="856" y2="435" stroke="#6b7d8d" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<line x1="1140" y1="435" x2="1231" y2="435" stroke="#6b7d8d" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="106" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
|
||||
<rect x="106" y="286" width="284" height="12" rx="6" fill="#ea8847"/>
|
||||
<circle cx="154" cy="344" r="25" fill="#fce3d1"/>
|
||||
<text x="146" y="351" class="badge" fill="#c8692b">1</text>
|
||||
<text x="194" y="351" class="step">КЛИЕНТ</text>
|
||||
<text x="142" y="412" class="card-title">Форма</text>
|
||||
<text x="142" y="452" class="code">name=avatar</text>
|
||||
<text x="142" y="496" class="card-text">имя и Content-Type</text>
|
||||
<text x="142" y="526" class="card-text">остаются входом,</text>
|
||||
<text x="142" y="556" class="card-text">а не решением.</text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="481" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
|
||||
<rect x="481" y="286" width="284" height="12" rx="6" fill="#6994c6"/>
|
||||
<circle cx="529" cy="344" r="25" fill="#dceafa"/>
|
||||
<text x="521" y="351" class="badge" fill="#4374af">2</text>
|
||||
<text x="569" y="351" class="step">PHP</text>
|
||||
<text x="517" y="412" class="card-title">Временный файл</text>
|
||||
<text x="517" y="452" class="code">$_FILES</text>
|
||||
<text x="517" y="496" class="card-text">error · size · tmp_name</text>
|
||||
<text x="517" y="526" class="card-text">Сначала убеждаемся,</text>
|
||||
<text x="517" y="556" class="card-text">что доставка завершилась.</text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="856" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
|
||||
<rect x="856" y="286" width="284" height="12" rx="6" fill="#4eaa93"/>
|
||||
<circle cx="904" cy="344" r="25" fill="#d6f1e8"/>
|
||||
<text x="896" y="351" class="badge" fill="#27846e">3</text>
|
||||
<text x="944" y="351" class="step">ПРОВЕРКА</text>
|
||||
<text x="892" y="412" class="card-title">Контракт</text>
|
||||
<text x="892" y="452" class="code">finfo_file()</text>
|
||||
<text x="892" y="496" class="card-text">JPEG / PNG · 2 МБ</text>
|
||||
<text x="892" y="526" class="card-text">размеры изображения</text>
|
||||
<text x="892" y="556" class="card-text">и белый список.</text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="1231" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
|
||||
<rect x="1231" y="286" width="284" height="12" rx="6" fill="#5d7f90"/>
|
||||
<circle cx="1279" cy="344" r="25" fill="#dce8ed"/>
|
||||
<text x="1271" y="351" class="badge" fill="#39586b">4</text>
|
||||
<text x="1319" y="351" class="step">ХРАНИЛИЩЕ</text>
|
||||
<text x="1267" y="412" class="card-title">Закрытый каталог</text>
|
||||
<text x="1267" y="452" class="code">7f4a...c2.png</text>
|
||||
<text x="1267" y="496" class="card-text">свой ключ приложения,</text>
|
||||
<text x="1267" y="526" class="card-text">никакого пути из</text>
|
||||
<text x="1267" y="556" class="card-text">исходного имени.</text>
|
||||
</g>
|
||||
|
||||
<rect x="106" y="688" width="1409" height="88" rx="20" fill="#ffffff" stroke="#c9dcda" stroke-width="2"/>
|
||||
<circle cx="158" cy="732" r="18" fill="#4eaa93"/>
|
||||
<path d="M149 732 l7 7 l13 -16" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="202" y="740" class="note">Перенос происходит только после проверки: из браузера в каталог попадает не «файл с именем», а запись, соответствующая контракту.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,86 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Границы доверия при загрузке файла</title>
|
||||
<desc id="desc">Диаграмма показывает клиентские метаданные, результат PHP, Fileinfo и решение приложения.</desc>
|
||||
<defs>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#f5f8fb"/>
|
||||
</linearGradient>
|
||||
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#142b3d" flood-opacity=".10"/>
|
||||
</filter>
|
||||
<marker id="flow-arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
|
||||
<path d="M 0 1 L 11 6 L 0 11 z" fill="#47687b"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 44px Inter, Arial, sans-serif; fill: #17324a; }
|
||||
.subtitle { font: 400 23px Inter, Arial, sans-serif; fill: #597080; }
|
||||
.lane { font: 700 17px Inter, Arial, sans-serif; letter-spacing: 2.2px; }
|
||||
.box-title { font: 700 27px Inter, Arial, sans-serif; fill: #17324a; }
|
||||
.box-copy { font: 400 20px Inter, Arial, sans-serif; fill: #466174; }
|
||||
.mono { font: 600 18px Menlo, Consolas, monospace; fill: #2f6174; }
|
||||
.small { font: 600 16px Inter, Arial, sans-serif; fill: #6b7d8d; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="#f5f8fb"/>
|
||||
<rect x="0" y="0" width="500" height="900" fill="#fff4ed"/>
|
||||
<rect x="500" y="0" width="520" height="900" fill="#eef5fb"/>
|
||||
<rect x="1020" y="0" width="580" height="900" fill="#eef8f4"/>
|
||||
<text x="94" y="104" class="title">Загрузка — это не один сигнал, а несколько границ</text>
|
||||
<text x="94" y="146" class="subtitle">Слева — то, что заявил клиент. Справа — то, на чём приложение строит решение.</text>
|
||||
|
||||
<text x="106" y="226" class="lane" fill="#c5682d">СВЕДЕНИЯ КЛИЕНТА</text>
|
||||
<text x="605" y="226" class="lane" fill="#4579a8">РЕЗУЛЬТАТ PHP</text>
|
||||
<text x="1125" y="226" class="lane" fill="#27846e">РЕШЕНИЕ ПРИЛОЖЕНИЯ</text>
|
||||
|
||||
<line x1="500" y1="184" x2="500" y2="774" stroke="#d78253" stroke-width="3" stroke-dasharray="10 12"/>
|
||||
<line x1="1020" y1="184" x2="1020" y2="774" stroke="#65a18f" stroke-width="3" stroke-dasharray="10 12"/>
|
||||
|
||||
<g filter="url(#soft-shadow)">
|
||||
<rect x="106" y="278" width="300" height="130" rx="20" fill="url(#panel)"/>
|
||||
<text x="138" y="326" class="box-title">Имя и расширение</text>
|
||||
<text x="138" y="364" class="mono">avatar.jpg</text>
|
||||
<text x="138" y="394" class="box-copy">Удобны для интерфейса.</text>
|
||||
|
||||
<rect x="106" y="452" width="300" height="130" rx="20" fill="url(#panel)"/>
|
||||
<text x="138" y="500" class="box-title">Content-Type</text>
|
||||
<text x="138" y="538" class="mono">image/jpeg</text>
|
||||
<text x="138" y="568" class="box-copy">Это поле multipart-части.</text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#soft-shadow)">
|
||||
<rect x="606" y="278" width="310" height="130" rx="20" fill="url(#panel)"/>
|
||||
<text x="638" y="326" class="box-title">Доставка</text>
|
||||
<text x="638" y="364" class="mono">UPLOAD_ERR_OK</text>
|
||||
<text x="638" y="394" class="box-copy">PHP принял файл целиком.</text>
|
||||
|
||||
<rect x="606" y="452" width="310" height="130" rx="20" fill="url(#panel)"/>
|
||||
<text x="638" y="500" class="box-title">Временный файл</text>
|
||||
<text x="638" y="538" class="mono">tmp_name · size</text>
|
||||
<text x="638" y="568" class="box-copy">Есть что проверять на сервере.</text>
|
||||
</g>
|
||||
|
||||
<g filter="url(#soft-shadow)">
|
||||
<rect x="1126" y="278" width="354" height="130" rx="20" fill="url(#panel)"/>
|
||||
<text x="1158" y="326" class="box-title">Fileinfo</text>
|
||||
<text x="1158" y="364" class="mono">finfo_file()</text>
|
||||
<text x="1158" y="394" class="box-copy">Определяет тип временного файла.</text>
|
||||
|
||||
<rect x="1126" y="452" width="354" height="130" rx="20" fill="url(#panel)"/>
|
||||
<text x="1158" y="500" class="box-title">Белый список</text>
|
||||
<text x="1158" y="538" class="mono">JPEG / PNG / 2 МБ</text>
|
||||
<text x="1158" y="568" class="box-copy">Правило конкретного сценария.</text>
|
||||
</g>
|
||||
|
||||
<path d="M406 343 C475 343 516 343 606 343" fill="none" stroke="#d78253" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
|
||||
<path d="M406 517 C480 517 532 517 606 517" fill="none" stroke="#d78253" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
|
||||
<path d="M916 343 C985 343 1051 343 1126 343" fill="none" stroke="#4e8ab8" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
|
||||
<path d="M916 517 C985 517 1051 517 1126 517" fill="none" stroke="#4e8ab8" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
|
||||
|
||||
<rect x="106" y="665" width="1374" height="102" rx="22" fill="#ffffff" stroke="#cfdfd9" stroke-width="2"/>
|
||||
<circle cx="156" cy="716" r="19" fill="#27846e"/>
|
||||
<path d="M147 716 l7 7 l14 -17" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="203" y="708" class="box-title">Практическое правило</text>
|
||||
<text x="203" y="742" class="box-copy">Имя и клиентский MIME-тип не определяют допуск. Они становятся полезными только после того, как серверный анализ и белый список дали ответ.</text>
|
||||
<text x="106" y="829" class="small">Отдельная проверка размеров дополняет контракт изображения, но не заменяет проверку типа.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -1,21 +1,38 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { editorialRevisions } from '../data/editorial-revisions.mjs';
|
||||
|
||||
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const articlesPath = join(webRoot, 'data', 'articles.json');
|
||||
const slugs = process.argv.slice(2);
|
||||
const archivedArticles = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||
const revisionBySlug = new Map(
|
||||
editorialRevisions.map((revision) => [revision.slug, revision]),
|
||||
);
|
||||
const archive = archivedArticles.map((article) => ({
|
||||
...article,
|
||||
...revisionBySlug.get(article.slug),
|
||||
}));
|
||||
const requestedSlugs = process.argv.slice(2);
|
||||
const slugs = requestedSlugs.includes('--all-editorial')
|
||||
? archive.filter((article) => article.slug.startsWith('editorial-')).map((article) => article.slug)
|
||||
: requestedSlugs;
|
||||
|
||||
if (slugs.length === 0) {
|
||||
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug]');
|
||||
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug] | --all-editorial');
|
||||
}
|
||||
|
||||
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||
const genericPhrases = [
|
||||
'У этой модели нет магической силы',
|
||||
'Материалы для проверки',
|
||||
'Если держать этот порядок, решение остаётся понятным',
|
||||
'В современном мире',
|
||||
'очень важно',
|
||||
'следует отметить',
|
||||
'просто нужно',
|
||||
'нужно понимать, что',
|
||||
];
|
||||
const MIN_BODY_CHARS = 5000;
|
||||
const MAX_BODY_CHARS = 15000;
|
||||
let failed = false;
|
||||
|
||||
function count(content, expression) {
|
||||
@@ -30,6 +47,12 @@ function plainText(content) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bodyText(content) {
|
||||
return plainText(
|
||||
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
|
||||
);
|
||||
}
|
||||
|
||||
for (const slug of slugs) {
|
||||
const article = archive.find((candidate) => candidate.slug === slug);
|
||||
const issues = [];
|
||||
@@ -41,19 +64,43 @@ for (const slug of slugs) {
|
||||
}
|
||||
|
||||
const content = article.contentHtml;
|
||||
const text = plainText(content);
|
||||
const body = bodyText(content);
|
||||
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
||||
const figures = [...content.matchAll(/<figure>([\s\S]*?)<\/figure>/g)].map((match) => match[1]);
|
||||
|
||||
if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения');
|
||||
if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста');
|
||||
if (body.length < MIN_BODY_CHARS) {
|
||||
issues.push('меньше ' + MIN_BODY_CHARS + ' знаков основного текста');
|
||||
}
|
||||
if (body.length > MAX_BODY_CHARS) {
|
||||
issues.push('больше ' + MAX_BODY_CHARS + ' знаков основного текста');
|
||||
}
|
||||
if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов');
|
||||
if (count(content, /<figure>/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения');
|
||||
if (count(content, /<figcaption>/g) < 1) issues.push('у иллюстрации нет подписи');
|
||||
if (count(content, /<table>/g) < 1 || count(content, /<thead>/g) < 1) issues.push('нет доступной таблицы');
|
||||
if (count(content, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера');
|
||||
if (count(content, /<ol>/g) < 1) issues.push('нет последовательности проверки или действий');
|
||||
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
|
||||
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками');
|
||||
if (content.includes('undefined') || content.includes('[object Object]')) issues.push('в тексте есть след генерации');
|
||||
const proseWithoutCode = content
|
||||
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
|
||||
.replace(/<code>[\s\S]*?<\/code>/g, '');
|
||||
if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) {
|
||||
issues.push('в тексте есть след генерации');
|
||||
}
|
||||
if (!/(проблем|ошиб|симптом|сбой|задач)/i.test(body.slice(0, 800))) {
|
||||
issues.push('проблема не названа в начале текста');
|
||||
}
|
||||
|
||||
for (const figure of figures) {
|
||||
const image = figure.match(/<img\b[^>]*>/);
|
||||
const alt = image?.[0].match(/\balt="([^"]*)"/);
|
||||
if (!image || !alt || alt[1].trim() === '') {
|
||||
issues.push('у рисунка нет содержательного alt-текста');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const phrase of genericPhrases) {
|
||||
if (content.includes(phrase)) issues.push('обнаружен шаблонный оборот: «' + phrase + '»');
|
||||
@@ -73,7 +120,7 @@ for (const slug of slugs) {
|
||||
} else {
|
||||
console.log(
|
||||
'PASS ' + slug
|
||||
+ ': ' + text.length + ' chars, '
|
||||
+ ': ' + body.length + ' body chars, '
|
||||
+ count(content, /<figure>/g) + ' figure, '
|
||||
+ count(content, /<table>/g) + ' table, '
|
||||
+ count(content, /<pre><code>/g) + ' code example',
|
||||
|
||||
@@ -92,6 +92,7 @@ const practiceArticle = {
|
||||
figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'),
|
||||
heading('Сначала формулируем контракт операции'),
|
||||
paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'),
|
||||
paragraph('Ещё один пункт контракта — повторный запуск. Импорт может оборваться после ответа базы или до записи в журнал. Поэтому внешний идентификатор должен позволять отличить новую операцию от повтора. В проекте это обычно означает отдельную проверку существующей записи по внешнему ключу и явное правило: обновляем черновик, пропускаем готовую запись или останавливаемся с конфликтом. Сам <code>Add</code> за это правило не отвечает.'),
|
||||
dataTable(
|
||||
['Участок', 'Что фиксируем', 'Чем доказываем'],
|
||||
[
|
||||
@@ -185,7 +186,7 @@ const mechanismArticle = {
|
||||
excerpt: 'Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.',
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('Когда Bitrix-проект разрастается, вокруг простого <code>CIBlockElement::Add</code> появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
|
||||
paragraph('Проблема появляется, когда Bitrix-проект разрастается вокруг простого <code>CIBlockElement::Add</code>: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
|
||||
heading('Карта жизненного цикла'),
|
||||
paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION->ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'),
|
||||
figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'),
|
||||
@@ -256,6 +257,12 @@ const mechanismArticle = {
|
||||
heading('После записи — это уже другой разговор'),
|
||||
paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как <code>Add</code> вернул ID, повторный вызов <code>Add</code> из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'),
|
||||
paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'),
|
||||
heading('Три вопроса до запуска'),
|
||||
orderedList([
|
||||
'Какой инвариант действительно общий для всех способов создания элемента, а какой относится только к форме или импорту?',
|
||||
'Где вызывающий код получит причину отказа: в результате сервиса, в <code>LAST_ERROR</code> или в отдельном журнале операции?',
|
||||
'Как повторный запуск отличит новую запись от уже созданной и не превратит сбой обработчика в дубликат?',
|
||||
]),
|
||||
heading('Как тестировать такую связку'),
|
||||
paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'),
|
||||
dataTable(
|
||||
@@ -285,6 +292,7 @@ const fieldArticle = {
|
||||
paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'),
|
||||
heading('Постановка проблемы'),
|
||||
paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'),
|
||||
paragraph('Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.'),
|
||||
figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'),
|
||||
heading('Проверяем по слоям'),
|
||||
dataTable(
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(text) {
|
||||
return '<pre><code>' + escapeHtml(text.trim()) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
const phpSetErrorHandler = {
|
||||
title: 'PHP manual: set_error_handler',
|
||||
url: 'https://www.php.net/manual/en/function.set-error-handler.php',
|
||||
note: 'какие ошибки передаются пользовательскому обработчику и какие типы он не перехватывает',
|
||||
};
|
||||
|
||||
const phpExceptionHandler = {
|
||||
title: 'PHP manual: set_exception_handler',
|
||||
url: 'https://www.php.net/manual/en/function.set-exception-handler.php',
|
||||
note: 'обработчик непойманного Throwable, который получает Error и Exception',
|
||||
};
|
||||
|
||||
const phpShutdown = {
|
||||
title: 'PHP manual: register_shutdown_function',
|
||||
url: 'https://www.php.net/manual/en/function.register-shutdown-function.php',
|
||||
note: 'когда PHP вызывает зарегистрированную функцию завершения',
|
||||
};
|
||||
|
||||
const phpLastError = {
|
||||
title: 'PHP manual: error_get_last',
|
||||
url: 'https://www.php.net/manual/en/function.error-get-last.php',
|
||||
note: 'формат последней ошибки: type, message, file и line',
|
||||
};
|
||||
|
||||
const phpCurlExec = {
|
||||
title: 'PHP manual: curl_exec',
|
||||
url: 'https://www.php.net/manual/en/function.curl-exec.php',
|
||||
note: 'строгое сравнение с false и отличие ошибки cURL от HTTP-статуса',
|
||||
};
|
||||
|
||||
const phpCurlInfo = {
|
||||
title: 'PHP manual: curl_getinfo',
|
||||
url: 'https://www.php.net/manual/en/function.curl-getinfo.php',
|
||||
note: 'данные последней передачи, включая http_code, content_type и total_time',
|
||||
};
|
||||
|
||||
const phpCurlErrno = {
|
||||
title: 'PHP manual: curl_errno',
|
||||
url: 'https://www.php.net/manual/en/function.curl-errno.php',
|
||||
note: 'код последней ошибки cURL и ноль при отсутствии ошибки',
|
||||
};
|
||||
|
||||
const httpSemantics = {
|
||||
title: 'RFC 7231, раздел 6: Response Status Codes',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6',
|
||||
note: 'семантика статус-кодов HTTP на уровне протокола',
|
||||
};
|
||||
|
||||
const phpJsonDecode = {
|
||||
title: 'PHP manual: json_decode',
|
||||
url: 'https://www.php.net/manual/en/function.json-decode.php',
|
||||
note: 'что возвращает декодер, требование UTF-8 и изменение PHP 7.3',
|
||||
};
|
||||
|
||||
const phpJsonLastError = {
|
||||
title: 'PHP manual: json_last_error',
|
||||
url: 'https://www.php.net/manual/en/function.json-last-error.php',
|
||||
note: 'коды ошибок последней операции JSON',
|
||||
};
|
||||
|
||||
const jsonRfc = {
|
||||
title: 'RFC 8259: JSON',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc8259.html',
|
||||
note: 'JSON допускает не только объект и массив, но и null, false, true, число и строку',
|
||||
};
|
||||
|
||||
const practiceArticle = {
|
||||
slug: 'editorial-2018-02-practice-php-diagnostics',
|
||||
title: 'PHP. Как записать причину 500-й ошибки в интеграции',
|
||||
categories: ['PHP', 'Отладка', 'Интеграции'],
|
||||
cover: '/assets/editorial/2018/php-fatal-context-flow.svg',
|
||||
excerpt: 'Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.',
|
||||
readingMinutes: 10,
|
||||
contentHtml: [
|
||||
paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один <code>try/catch</code> вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса?'),
|
||||
heading('Почему одного set_error_handler недостаточно'),
|
||||
paragraph('Первое, что обычно хочется сделать, — повесить <code>set_error_handler</code> и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает <code>E_ERROR</code>, <code>E_PARSE</code>, <code>E_CORE_ERROR</code> и <code>E_COMPILE_ERROR</code>. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.'),
|
||||
paragraph('Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или <code>Error</code> в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после <code>exit()</code>, а <code>error_get_last()</code> даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.'),
|
||||
figure('/assets/editorial/2018/php-fatal-context-flow.svg', 'Схема: контекст операции создаётся перед интеграцией; предупреждение идёт в set_error_handler, исключение — в set_exception_handler, фатальная ошибка проверяется при shutdown', 'Один request ID проходит через все три ветки. В журнале видно не только текст PHP, но и операцию, на которой он возник.'),
|
||||
heading('Сначала определить, что именно нужно найти потом'),
|
||||
paragraph('Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком <code>$_POST</code>, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.'),
|
||||
paragraph('Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: <code>request_prepared</code>, <code>partner_called</code>, <code>response_saved</code>. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.'),
|
||||
dataTable(
|
||||
['Поле журнала', 'Пример', 'Зачем оно нужно'],
|
||||
[
|
||||
['<code>request_id</code>', '<code>sync-20180207-4f2a</code>', 'Связать запись PHP с логом веб-сервера и сообщением партнёра'],
|
||||
['<code>operation</code>', '<code>order_export</code>', 'Не смешать импорт каталога, webhook и ручной запуск'],
|
||||
['<code>external_id</code>', '<code>ORD-9182</code>', 'Повторить один сценарий без поиска по всему набору данных'],
|
||||
['<code>stage</code>', '<code>partner_called</code>', 'Понять, успел ли код дойти до внешнего вызова'],
|
||||
['<code>error_type</code>', '<code>E_ERROR</code> или <code>Throwable</code>', 'Отделить ошибку PHP от ответа HTTP'],
|
||||
['<code>file</code>, <code>line</code>', 'путь и строка', 'Открыть точку падения в той версии кода, которая работала в момент сбоя'],
|
||||
],
|
||||
),
|
||||
heading('Минимальная обвязка для PHP 7'),
|
||||
paragraph('Ниже пример для одного HTTP-запроса. Он не пытается перехватить всё подряд и не меняет поведение штатного обработчика PHP: после записи предупреждения возвращается <code>false</code>. Это удобно на первом внедрении: существующие настройки <code>error_reporting</code> и журнал сервера остаются на месте, а рядом появляется структурированная запись для интеграции.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
|
||||
function writeIntegrationLog(array $record)
|
||||
{
|
||||
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
function installIntegrationDiagnostics($requestId, $operation, $externalId)
|
||||
{
|
||||
$context = array(
|
||||
'request_id' => $requestId,
|
||||
'operation' => $operation,
|
||||
'external_id' => $externalId,
|
||||
'stage' => 'started',
|
||||
);
|
||||
|
||||
$setStage = function ($stage) use (&$context) {
|
||||
$context['stage'] = $stage;
|
||||
};
|
||||
|
||||
set_error_handler(function ($severity, $message, $file, $line) use (&$context) {
|
||||
if (!(error_reporting() & $severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
writeIntegrationLog($context + array(
|
||||
'kind' => 'php_error',
|
||||
'error_type' => $severity,
|
||||
'message' => $message,
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
));
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
set_exception_handler(function (Throwable $error) use (&$context) {
|
||||
writeIntegrationLog($context + array(
|
||||
'kind' => 'uncaught_throwable',
|
||||
'class' => get_class($error),
|
||||
'message' => $error->getMessage(),
|
||||
'file' => $error->getFile(),
|
||||
'line' => $error->getLine(),
|
||||
));
|
||||
});
|
||||
|
||||
register_shutdown_function(function () use (&$context) {
|
||||
$last = error_get_last();
|
||||
$fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR);
|
||||
|
||||
if ($last === null || !in_array($last['type'], $fatalTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
writeIntegrationLog($context + array(
|
||||
'kind' => 'fatal_error',
|
||||
'error_type' => $last['type'],
|
||||
'message' => $last['message'],
|
||||
'file' => $last['file'],
|
||||
'line' => $last['line'],
|
||||
));
|
||||
});
|
||||
|
||||
return $setStage;
|
||||
}
|
||||
|
||||
$setStage = installIntegrationDiagnostics(
|
||||
'sync-20180207-4f2a',
|
||||
'order_export',
|
||||
'ORD-9182'
|
||||
);
|
||||
|
||||
$setStage('request_prepared');
|
||||
// Здесь вызывается клиент партнёра.
|
||||
$setStage('partner_called');
|
||||
`),
|
||||
paragraph('В настоящем коде генерация <code>request_id</code> и запись журнала обычно живут в приложении, а не в каждой интеграции. Здесь они оставлены рядом, чтобы видно было главное: контекст создаётся до внешнего вызова, а не в блоке обработки ошибки. Нельзя восстановить по фатальной ошибке то, что код не успел записать.'),
|
||||
heading('Как проверить схему до аварии'),
|
||||
paragraph('Проверять такую обвязку лучше не на боевом заказе. Для предупреждения достаточно отдельного скрипта с <code>trigger_error("diagnostic test", E_USER_WARNING)</code>. Для исключения — выбросить <code>RuntimeException</code> после установки этапа. Фатальный путь нужно запускать только в изолированной среде: ошибка, которую нельзя перехватить через <code>set_error_handler</code>, должна оставить запись из shutdown-функции, а сам тест не должен менять состояние сторонней системы.'),
|
||||
orderedList([
|
||||
'Добавить обвязку в точку входа до вызова клиента интеграции и задать <code>request_id</code>, операцию и внешний ID.',
|
||||
'Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.',
|
||||
'Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.',
|
||||
'В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.',
|
||||
'Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.',
|
||||
]),
|
||||
heading('Где эта схема заканчивается'),
|
||||
paragraph('Она не ловит синтаксическую ошибку в файле, который не дал приложению стартовать: обработчики ещё не зарегистрированы. Она не гарантирует запись при принудительном завершении процесса. Она не заменяет мониторинг 500-х на уровне веб-сервера. И она не даёт права сохранять секреты в журнал. Для таких случаев остаются деплой-проверки, журналы окружения и правила маскирования данных.'),
|
||||
paragraph('Ещё одна граница — дубли. Ошибка внутри <code>set_error_handler</code> и ошибка в shutdown-функции не должны сами вызвать бесконечный поток записей. Поэтому запись должна быть короткой, а логгер — максимально простым. Если для доставки лога нужен сетевой запрос, я бы не ставил его в shutdown-путь: при падении сети потеряем и исходную ошибку, и время на разбор.'),
|
||||
heading('Порядок, который остаётся в проекте'),
|
||||
paragraph('Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.'),
|
||||
heading('Проверяемые источники'),
|
||||
sourceList([phpSetErrorHandler, phpExceptionHandler, phpShutdown, phpLastError]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const mechanismArticle = {
|
||||
slug: 'editorial-2018-02-mechanism-php-diagnostics',
|
||||
title: 'PHP и cURL. Почему curl_exec() не означает успех интеграции',
|
||||
categories: ['PHP', 'cURL', 'Интеграции'],
|
||||
cover: '/assets/editorial/2018/curl-outcome-classifier.svg',
|
||||
excerpt: 'curl_exec() может вернуть тело ответа, хотя партнёр ответил 404 или 500. Разбираю три уровня результата: транспорт, HTTP и контракт полезной нагрузки.',
|
||||
readingMinutes: 10,
|
||||
contentHtml: [
|
||||
paragraph('После ночной выгрузки в логе стоит «запрос выполнен», потому что <code>curl_exec()</code> вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?'),
|
||||
heading('У одного вызова три разных результата'),
|
||||
paragraph('При включённом <code>CURLOPT_RETURNTRANSFER</code> функция <code>curl_exec()</code> возвращает тело ответа при успехе cURL и <code>false</code> при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой <code>"0"</code>, которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через <code>curl_getinfo()</code>.'),
|
||||
paragraph('Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: <code>$body === false</code>, <code>curl_errno()</code> и <code>curl_error()</code>. Затем читаем <code>http_code</code>, тип содержимого и время из <code>curl_getinfo()</code>. Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.'),
|
||||
figure('/assets/editorial/2018/curl-outcome-classifier.svg', 'Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела', 'Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.'),
|
||||
heading('Что сохранять для каждого уровня'),
|
||||
dataTable(
|
||||
['Наблюдение', 'Класс сбоя', 'Что записать в журнал', 'Следующее действие'],
|
||||
[
|
||||
['<code>$body === false</code>', 'Транспорт или TLS', '<code>curl_errno</code>, <code>curl_error</code>, URL без секрета, время', 'Проверить DNS, сертификат, таймаут и доступность хоста'],
|
||||
['Есть тело, <code>http_code</code> 401 или 403', 'Авторизация или права', 'HTTP-код, операция, внешний ID, request ID', 'Проверить учётные данные и область доступа; не печатать токен'],
|
||||
['Есть тело, <code>http_code</code> 404', 'Адрес или версия API', 'HTTP-код и маршрут без query-параметров', 'Сверить путь, метод и версию endpoint'],
|
||||
['Есть тело, <code>http_code</code> 500', 'Ошибка удалённой стороны', 'HTTP-код, request ID, первые безопасные признаки ответа', 'Передать партнёру ID запроса и время, не повторять запись вслепую'],
|
||||
['2xx и ожидаемое тело', 'Транспорт и HTTP прошли', 'Код, размер и время ответа', 'Проверить обязательные поля тела перед изменением локальных данных'],
|
||||
],
|
||||
),
|
||||
heading('Клиент, который не прячет уровень ошибки'),
|
||||
paragraph('В примере ниже нет общего «интеграционного клиента». Нужна маленькая функция, которую легко вызвать в изолированном скрипте и легко покрыть разными ответами. Время соединения и общий таймаут здесь проектные: их надо выбирать под договорённость с конкретным сервисом, а не переносить числа из чужого кода.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
|
||||
function requestPartner($url, $requestId)
|
||||
{
|
||||
$handle = curl_init($url);
|
||||
|
||||
curl_setopt_array($handle, array(
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Accept: application/json',
|
||||
'X-Request-Id: ' . $requestId,
|
||||
),
|
||||
));
|
||||
|
||||
$body = curl_exec($handle);
|
||||
$curlErrno = curl_errno($handle);
|
||||
$curlError = curl_error($handle);
|
||||
$info = curl_getinfo($handle);
|
||||
curl_close($handle);
|
||||
|
||||
if ($body === false) {
|
||||
throw new RuntimeException(json_encode(array(
|
||||
'kind' => 'transport_error',
|
||||
'request_id' => $requestId,
|
||||
'curl_errno' => $curlErrno,
|
||||
'curl_error' => $curlError,
|
||||
'total_time' => $info['total_time'],
|
||||
)));
|
||||
}
|
||||
|
||||
$status = (int) $info['http_code'];
|
||||
if ($status < 200 || $status >= 300) {
|
||||
throw new RuntimeException(json_encode(array(
|
||||
'kind' => 'http_error',
|
||||
'request_id' => $requestId,
|
||||
'http_code' => $status,
|
||||
'content_type' => $info['content_type'],
|
||||
'body_bytes' => strlen($body),
|
||||
'total_time' => $info['total_time'],
|
||||
)));
|
||||
}
|
||||
|
||||
return array(
|
||||
'body' => $body,
|
||||
'content_type' => $info['content_type'],
|
||||
'http_code' => $status,
|
||||
'total_time' => $info['total_time'],
|
||||
);
|
||||
}
|
||||
`),
|
||||
paragraph('Функция намеренно не пишет URL целиком. Query-параметры часто содержат ключи, подписи или персональные идентификаторы. Если адрес нужен для расследования, лучше сохранить имя интеграции и заранее нормализованный путь. Тело ответа тоже не стоит бездумно добавлять к исключению: для первичного поиска достаточно размера, типа содержимого и ID операции; безопасный фрагмент можно сохранить отдельно в тестовой среде.'),
|
||||
heading('Почему 2xx — ещё не результат операции'),
|
||||
paragraph('HTTP-код описывает ответ сервера на протокольном уровне. Он не может за нас подтвердить, что партнёр принял заказ в нужном виде. Один API возвращает <code>{"id":"A-17"}</code>, другой — <code>{"accepted":true}</code>, третий ставит задачу в очередь. Поэтому после 2xx должна быть проверка конкретного поля, которое выбрано в контракте. Разбор JSON и формы ответа — отдельная граница; её нельзя заменять условием <code>if ($body)</code>.'),
|
||||
paragraph('Это же объясняет, почему автоматический повтор записи нельзя включать как реакцию на любой сбой. При таймауте неизвестно, дошёл ли запрос до партнёра. Если операция создаёт заказ, второй POST может создать дубликат. Повтор становится безопасным только когда протокол даёт ключ идемпотентности, внешний ID или отдельный способ узнать результат первой попытки. Пока такого условия нет, в журнале должна появиться операция для разбора, а не второй запрос в фоне.'),
|
||||
heading('Воспроизводимая матрица проверки'),
|
||||
paragraph('Для проверки не нужен настоящий партнёр. Достаточно небольшого тестового endpoint, который по параметру возвращает 200 с JSON, 401, 500 и закрывает соединение. Важно сравнивать не только текст исключения, но и поля записи: у каждого сценария должен быть свой <code>kind</code>. Тогда мониторинг может отдельно считать транспортные сбои и ответы 5xx.'),
|
||||
orderedList([
|
||||
'Включить <code>CURLOPT_RETURNTRANSFER</code> и заменить все проверки <code>if (!$body)</code> на строгое <code>$body === false</code>.',
|
||||
'Сразу после <code>curl_exec()</code> собрать <code>curl_errno</code>, <code>curl_error</code> и <code>curl_getinfo</code>, пока handle не закрыт.',
|
||||
'Прогнать endpoint с недоступным адресом и проверить ветку <code>transport_error</code> с ненулевым кодом cURL.',
|
||||
'Прогнать 401, 404 и 500; у них должна сработать ветка <code>http_error</code>, а не транспортная ошибка.',
|
||||
'Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.',
|
||||
]),
|
||||
heading('Границы примера'),
|
||||
paragraph('Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.'),
|
||||
paragraph('Но даже такая небольшая развилка меняет качество диагностики. Вместо одного сообщения «интеграция не работает» появляются три проверяемых факта: передача не состоялась, удалённый сервер ответил не тем статусом или статус нормальный, но тело не прошло контракт. Дальше можно обсуждать решение с человеком, который отвечает именно за этот слой.'),
|
||||
heading('Проверяемые источники'),
|
||||
sourceList([phpCurlExec, phpCurlInfo, phpCurlErrno, httpSemantics]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const fieldArticle = {
|
||||
slug: 'editorial-2018-02-field-php-diagnostics',
|
||||
title: 'PHP. Как отличить битый JSON от корректного null в ответе API',
|
||||
categories: ['PHP', 'JSON', 'Интеграции'],
|
||||
cover: '/assets/editorial/2018/json-payload-diagnostic.svg',
|
||||
excerpt: 'Проверка if (!$data) смешивает пустой массив, false, null и ошибку декодирования. Собираем короткий разбор JSON для PHP 7.1 с проверкой json_last_error и контракта ответа.',
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('В обработчике ответа часто встречается одна строка: <code>if (!$data) { throw new Exception("bad response"); }</code>. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное <code>null</code>, число <code>0</code> или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет <code>JSON_THROW_ON_ERROR</code>, поэтому после <code>json_decode()</code> нужно явно проверить состояние декодера.'),
|
||||
paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем <code>json_last_error()</code>. Только если там <code>JSON_ERROR_NONE</code>, проверяем тип и обязательные поля ответа.'),
|
||||
heading('Почему null не доказывает ошибку'),
|
||||
paragraph('По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, <code>false</code>, <code>true</code> и <code>null</code>. PHP отражает это напрямую: <code>json_decode("null")</code> возвращает <code>null</code>, но <code>null</code> возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.'),
|
||||
paragraph('То же происходит с пустыми коллекциями. После <code>json_decode("[]", true)</code> получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же <code>if (!$data)</code> назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.'),
|
||||
figure('/assets/editorial/2018/json-payload-diagnostic.svg', 'Схема диагностики JSON: сырой ответ сначала проходит json_decode и json_last_error, затем проверку типа и обязательных полей контракта', 'Ошибка декодирования и нарушение контракта — разные события. У них разные владельцы и разные действия.'),
|
||||
heading('Короткая таблица, которую стоит держать рядом с кодом'),
|
||||
dataTable(
|
||||
['Сырой ответ', 'Результат json_decode(..., true)', 'json_last_error', 'Что это значит для клиента'],
|
||||
[
|
||||
['<code>{"order_id":"A-17"}</code>', 'ассоциативный массив', '<code>JSON_ERROR_NONE</code>', 'Проверить поле <code>order_id</code> и принять ответ'],
|
||||
['<code>[]</code>', 'пустой массив', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON; допустимость зависит от операции'],
|
||||
['<code>null</code>', '<code>null</code>', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON, но не тот тип, который ждёт данный endpoint'],
|
||||
['<code>false</code> или <code>0</code>', '<code>false</code> или <code>0</code>', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON; проверка на «ложь» здесь ошибочна'],
|
||||
['<code><html>503</html></code>', 'обычно <code>null</code>', '<code>JSON_ERROR_SYNTAX</code>', 'Неверный формат ответа; сохранить безопасный диагностический контекст'],
|
||||
],
|
||||
),
|
||||
heading('Пример для PHP 7.1'),
|
||||
paragraph('Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым <code>order_id</code>. В другом API это может быть список, поле <code>accepted</code> или код задачи — меняется проверка контракта, но не порядок диагностики.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
|
||||
function logPayloadProblem(array $record)
|
||||
{
|
||||
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
function rejectPayloadContract($reason, $requestId, $body)
|
||||
{
|
||||
logPayloadProblem(array(
|
||||
'kind' => 'contract_error',
|
||||
'reason' => $reason,
|
||||
'request_id' => $requestId,
|
||||
'body_bytes' => strlen($body),
|
||||
'body_sha256' => hash('sha256', $body),
|
||||
));
|
||||
|
||||
throw new UnexpectedValueException($reason);
|
||||
}
|
||||
|
||||
function decodeCreatedOrder($body, $requestId)
|
||||
{
|
||||
if ($body === '') {
|
||||
rejectPayloadContract('Partner returned an empty body', $requestId, $body);
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
$jsonError = json_last_error();
|
||||
|
||||
if ($jsonError !== JSON_ERROR_NONE) {
|
||||
logPayloadProblem(array(
|
||||
'kind' => 'json_decode_error',
|
||||
'request_id' => $requestId,
|
||||
'json_error' => $jsonError,
|
||||
'body_bytes' => strlen($body),
|
||||
'body_sha256' => hash('sha256', $body),
|
||||
));
|
||||
|
||||
throw new UnexpectedValueException('Partner response is not valid JSON');
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
rejectPayloadContract(
|
||||
'Partner returned valid JSON, but not an object',
|
||||
$requestId,
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!array_key_exists('order_id', $data)
|
||||
|| !is_string($data['order_id'])
|
||||
|| $data['order_id'] === ''
|
||||
) {
|
||||
rejectPayloadContract(
|
||||
'Partner JSON has no non-empty order_id',
|
||||
$requestId,
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
`),
|
||||
paragraph('Значение <code>json_last_error()</code> читается сразу после <code>json_decode()</code>. Это состояние относится к последней операции JSON, поэтому его легко затереть следующим <code>json_encode()</code> или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.'),
|
||||
heading('Не путать формат с договором'),
|
||||
paragraph('Предположим, партнёр ответил <code>[]</code>. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.'),
|
||||
paragraph('Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле <code>order_id</code> отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.'),
|
||||
heading('Проверка на четырёх маленьких ответах'),
|
||||
paragraph('Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.'),
|
||||
orderedList([
|
||||
'Передать <code>{"order_id":"A-17"}</code> и проверить, что функция вернула массив с идентификатором.',
|
||||
'Передать <code><html>maintenance</html></code>; ожидается ветка <code>json_decode_error</code> с кодом <code>JSON_ERROR_SYNTAX</code>.',
|
||||
'Передать <code>null</code>; <code>json_last_error()</code> должен показать успех разбора, а функция должна отклонить неподходящий тип.',
|
||||
'Передать <code>[]</code>; разбор успешен, но контракт создания заказа должен отклонить отсутствие <code>order_id</code>.',
|
||||
'Отдельно проверить поиск или список, где <code>[]</code> является валидным результатом, чтобы не переносить правила одной операции на другую.',
|
||||
]),
|
||||
heading('Версия PHP и ограничения'),
|
||||
paragraph('В PHP 7.3 появился флаг <code>JSON_THROW_ON_ERROR</code>. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка <code>json_last_error()</code> — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.'),
|
||||
paragraph('Декодер ожидает строку в UTF-8. Ошибка <code>JSON_ERROR_UTF8</code> говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.'),
|
||||
heading('Что оставить после исправления'),
|
||||
paragraph('После этой доработки в клиенте остаются два разных события: <code>json_decode_error</code> для невалидного формата и <code>contract_error</code> для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие <code>if (!$data)</code> исчезает: оно не способно сказать, что именно произошло.'),
|
||||
heading('Проверяемые источники'),
|
||||
sourceList([phpJsonDecode, phpJsonLastError, jsonRfc]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function assertRevisionQuality(revision) {
|
||||
const body = plainText(revision.contentHtml);
|
||||
const issues = [];
|
||||
|
||||
if (body.length < 5000 || body.length > 15000) {
|
||||
issues.push('основной текст: ' + body.length + ' знаков');
|
||||
}
|
||||
if ((revision.contentHtml.match(/<figure>/g) || []).length !== 1) {
|
||||
issues.push('нужен ровно один главный рисунок');
|
||||
}
|
||||
if (!revision.contentHtml.includes('<table>')) issues.push('нет таблицы');
|
||||
if (!revision.contentHtml.includes('<pre><code>')) issues.push('нет примера кода');
|
||||
if (!revision.contentHtml.includes('<ol>')) issues.push('нет последовательности действий');
|
||||
if (!revision.contentHtml.includes('<h2>Проверяемые источники</h2>')) {
|
||||
issues.push('нет раздела с источниками');
|
||||
}
|
||||
if ((revision.contentHtml.match(/<a href="https?:\/\//g) || []).length < 2) {
|
||||
issues.push('меньше двух источников');
|
||||
}
|
||||
if (revision.contentHtml.includes('undefined') || revision.contentHtml.includes('[object Object]')) {
|
||||
issues.push('в тексте есть след генерации');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw new Error(revision.slug + ': ' + issues.join('; '));
|
||||
}
|
||||
}
|
||||
|
||||
for (const revision of revisions) {
|
||||
assertRevisionQuality(revision);
|
||||
}
|
||||
|
||||
if (!process.argv.includes('--print-revisions')) {
|
||||
throw new Error('Usage: node scripts/upgrade-2018-02.mjs --print-revisions');
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
@@ -0,0 +1,412 @@
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const paragraph = (content) => '<p>' + content + '</p>';
|
||||
const heading = (content) => '<h2>' + content + '</h2>';
|
||||
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
|
||||
const figure = (src, alt, caption) => [
|
||||
'<figure>',
|
||||
'<img src="' + src + '" alt="' + alt + '" />',
|
||||
'<figcaption>' + caption + '</figcaption>',
|
||||
'</figure>',
|
||||
].join('');
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||||
const body = rows.map((row) => (
|
||||
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
|
||||
)).join('');
|
||||
|
||||
return '<div class="table-scroll"><table><thead><tr>' + head
|
||||
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function sourceList(sources) {
|
||||
return '<ul>' + sources.map(({ title, url }) => (
|
||||
'<li><a href="' + url + '" target="_blank" rel="noopener">' + title + '</a></li>'
|
||||
)).join('') + '</ul>';
|
||||
}
|
||||
|
||||
const phpUploadErrors = {
|
||||
title: 'PHP Manual: коды ошибок загрузки',
|
||||
url: 'https://www.php.net/manual/en/features.file-upload.errors.php',
|
||||
};
|
||||
const phpMoveUploadedFile = {
|
||||
title: 'PHP Manual: move_uploaded_file',
|
||||
url: 'https://www.php.net/manual/en/function.move-uploaded-file.php',
|
||||
};
|
||||
const phpFileinfo = {
|
||||
title: 'PHP Manual: finfo_file',
|
||||
url: 'https://www.php.net/manual/en/function.finfo-file.php',
|
||||
};
|
||||
const phpGetImageSize = {
|
||||
title: 'PHP Manual: getimagesize и его ограничение как валидатора',
|
||||
url: 'https://www.php.net/manual/en/function.getimagesize.php',
|
||||
};
|
||||
const phpHeader = {
|
||||
title: 'PHP Manual: header',
|
||||
url: 'https://www.php.net/manual/en/function.header.php',
|
||||
};
|
||||
const phpReadfile = {
|
||||
title: 'PHP Manual: readfile',
|
||||
url: 'https://www.php.net/manual/en/function.readfile.php',
|
||||
};
|
||||
const multipartRfc = {
|
||||
title: 'RFC 7578: multipart/form-data',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc7578',
|
||||
};
|
||||
const contentDispositionRfc = {
|
||||
title: 'RFC 6266: Content-Disposition в HTTP',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc6266',
|
||||
};
|
||||
const owaspUpload = {
|
||||
title: 'OWASP File Upload Cheat Sheet',
|
||||
url: 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html',
|
||||
};
|
||||
|
||||
const practiceArticle = {
|
||||
slug: 'editorial-2018-03-practice-safe-uploads',
|
||||
title: 'PHP. Безопасная загрузка аватара: минимальный маршрут без доверия к имени файла',
|
||||
categories: ['PHP', 'Безопасность'],
|
||||
cover: '/assets/editorial/2018/php-upload-avatar-contract.svg',
|
||||
excerpt: 'Собираем маленький обработчик для JPEG и PNG: проверяем доставку, размер и содержимое, сохраняем под своим именем и не отдаём путь из веб-корня.',
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('Загрузка аватара обычно начинается с одного поля формы и вызова <code>move_uploaded_file</code>. Ошибка становится заметна позже: каталог <code>uploads</code> оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к <code>.jpg</code>. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.'),
|
||||
paragraph('Вопрос этой заметки один: <strong>как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности?</strong> Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения.'),
|
||||
heading('Сначала договоримся о результате'),
|
||||
paragraph('Форма передаёт один файл <code>avatar</code>. Мы принимаем не более 2 МБ, только <code>image/jpeg</code> и <code>image/png</code>, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например <code>7f4a...c2.png</code>. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/php-upload-avatar-contract.svg',
|
||||
'Путь файла аватара: браузер передаёт multipart-часть, PHP создаёт временный файл, код проверяет его и переносит в закрытое хранилище под сгенерированным ключом.',
|
||||
'Проверки идут до переноса. После переноса остаётся ключ приложения, а не имя из формы.',
|
||||
),
|
||||
dataTable(
|
||||
['Проверка', 'Что она отвечает', 'Что делаем при отказе'],
|
||||
[
|
||||
['<code>UPLOAD_ERR_OK</code>', 'PHP полностью принял часть запроса', 'Не читаем временный путь, показываем понятную ошибку загрузки'],
|
||||
['Лимит 2 МБ', 'Файл укладывается в договор аватара', 'Не переносим файл и не пытаемся уменьшать его вслепую'],
|
||||
['<code>finfo_file</code>', 'Какой MIME-тип определён по временному файлу', 'Отклоняем тип, которого нет в белом списке'],
|
||||
['Размеры изображения', 'Подходит ли картинка для интерфейса', 'Отклоняем слишком маленькое или слишком большое изображение'],
|
||||
['Сгенерированный ключ', 'Куда именно будет записан файл', 'Никогда не составляем путь из исходного имени'],
|
||||
],
|
||||
),
|
||||
heading('Обработчик без скрытого шага'),
|
||||
paragraph('Проверка <code>$_FILES["avatar"]["error"]</code> должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле <code>type</code> из <code>$_FILES</code> здесь намеренно не используется: его прислал клиент.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
|
||||
function storeAvatar(array $file, string $privateDir): array
|
||||
{
|
||||
if (!isset($file['error'], $file['tmp_name'], $file['size'])) {
|
||||
throw new RuntimeException('Поле avatar передано в неверном формате');
|
||||
}
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('PHP не принял файл: код ' . $file['error']);
|
||||
}
|
||||
|
||||
$maxBytes = 2 * 1024 * 1024;
|
||||
if ((int)$file['size'] > $maxBytes) {
|
||||
throw new RuntimeException('Аватар больше 2 МБ');
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo === false) {
|
||||
throw new RuntimeException('Расширение Fileinfo недоступно');
|
||||
}
|
||||
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
$allowed = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
];
|
||||
|
||||
if (!is_string($mime) || !isset($allowed[$mime])) {
|
||||
throw new RuntimeException('Нужен JPEG или PNG');
|
||||
}
|
||||
|
||||
$size = getimagesize($file['tmp_name']);
|
||||
if ($size === false) {
|
||||
throw new RuntimeException('Не удалось прочитать размеры изображения');
|
||||
}
|
||||
|
||||
list($width, $height) = $size;
|
||||
if ($width < 64 || $height < 64 || $width > 3000 || $height > 3000) {
|
||||
throw new RuntimeException('Размеры изображения вне допустимого диапазона');
|
||||
}
|
||||
|
||||
$storageKey = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
|
||||
$target = rtrim($privateDir, DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR . $storageKey;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $target)) {
|
||||
throw new RuntimeException('Не удалось сохранить аватар');
|
||||
}
|
||||
|
||||
return [
|
||||
'storageKey' => $storageKey,
|
||||
'mime' => $mime,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
];
|
||||
}
|
||||
`),
|
||||
heading('Почему порядок проверок важнее набора функций'),
|
||||
paragraph('У <code>move_uploaded_file</code> есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам.'),
|
||||
paragraph('Вызов <code>getimagesize</code> нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.'),
|
||||
heading('Минимальная форма и проверка руками'),
|
||||
codeBlock(String.raw`
|
||||
<form method="post" enctype="multipart/form-data" action="/profile/avatar.php">
|
||||
<input type="file" name="avatar" accept="image/jpeg,image/png" required>
|
||||
<button type="submit">Сохранить аватар</button>
|
||||
</form>
|
||||
`),
|
||||
paragraph('Атрибут <code>accept</code> помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением <code>.jpg</code> и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.'),
|
||||
heading('Порядок запуска'),
|
||||
orderedList([
|
||||
'Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.',
|
||||
'Подключить форму с <code>multipart/form-data</code> и передать <code>$_FILES["avatar"]</code> в функцию.',
|
||||
'После успешного вызова сохранить только <code>storageKey</code>, MIME-тип и размеры рядом с пользователем.',
|
||||
'Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.',
|
||||
'Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.',
|
||||
]),
|
||||
heading('Граница этого примера'),
|
||||
paragraph('Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.'),
|
||||
heading('Проверяемые источники'),
|
||||
sourceList([phpUploadErrors, phpMoveUploadedFile, phpFileinfo, phpGetImageSize, owaspUpload]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const mechanismArticle = {
|
||||
slug: 'editorial-2018-03-mechanism-safe-uploads',
|
||||
title: 'PHP. Почему расширение и Content-Type не отвечают на вопрос «что за файл?»',
|
||||
categories: ['PHP', 'Безопасность'],
|
||||
cover: '/assets/editorial/2018/php-upload-trust-signals.svg',
|
||||
excerpt: 'Разбираем, какие сведения о загрузке пришли от клиента, какие получил PHP и где серверу действительно стоит принимать решение о допустимом файле.',
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('Симптом: обработчик пропускает файл с <code>type=image/jpeg</code>, хотя Fileinfo для временного файла определяет другой тип. Цена ошибки — приложение сохраняет и позднее выдаёт контент, которого этот маршрут не должен был принимать. Самая коварная строка в обработчике загрузки выглядит безобидно: <code>if ($file["type"] === "image/jpeg")</code>. Она работает с обычным браузером и ломает модель в тот момент, когда запрос собран не браузером. В multipart-форме имя файла и Content-Type — часть сообщения клиента. Сервер получает эти поля, но не обязан считать их доказательством содержимого.'),
|
||||
paragraph('Главный вопрос статьи: <strong>какие признаки файла можно использовать для какой проверки?</strong> Ответ не сводится к одной «правильной» функции. У доставки, типа, размеров и имени разные источники, поэтому их нельзя склеивать в одну проверку с красивым названием <code>validateUpload()</code>.'),
|
||||
heading('Где заканчиваются сведения клиента'),
|
||||
paragraph('RFC 7578 описывает <code>multipart/form-data</code>: файл приходит отдельной частью с заголовками, среди которых может быть Content-Type. Это формат передачи, а не подпись под содержимым. PHP раскладывает результат в <code>$_FILES</code>; там есть исходное имя, клиентский тип, размер, временный путь и код ошибки. У каждого поля своя ценность.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/php-upload-trust-signals.svg',
|
||||
'Схема границ доверия: имя и Content-Type идут от клиента, PHP сообщает результат доставки, Fileinfo изучает временный файл, а приложение применяет собственный белый список.',
|
||||
'Клиентские метаданные полезны для интерфейса и диагностики. Решение о допуске принимает приложение после проверки временного файла.',
|
||||
),
|
||||
dataTable(
|
||||
['Сигнал', 'Откуда он взялся', 'Правильное применение'],
|
||||
[
|
||||
['<code>$file["name"]</code>', 'Имя, переданное клиентом', 'Показать как подпись после экранирования; не строить из него путь'],
|
||||
['Расширение', 'Часть клиентского имени', 'Использовать как удобный фильтр интерфейса, но не как доказательство типа'],
|
||||
['<code>$file["type"]</code>', 'Content-Type multipart-части', 'Сохранить в отладочном журнале, но не использовать для допуска'],
|
||||
['<code>$file["error"]</code>', 'Результат, который сообщил PHP', 'Продолжать только при <code>UPLOAD_ERR_OK</code>'],
|
||||
['<code>finfo_file()</code>', 'Анализ временного файла на сервере', 'Сравнить с точным белым списком допустимых MIME-типов'],
|
||||
['<code>getimagesize()</code>', 'Попытка прочитать параметры изображения', 'Проверить размеры после Fileinfo, но не считать это проверкой безопасности'],
|
||||
],
|
||||
),
|
||||
heading('Короткий опыт на локальной машине'),
|
||||
paragraph('Ниже не нужен вредоносный файл. Достаточно обычного текста и вручную заданного Content-Type. Поднимите встроенный сервер PHP в каталоге с <code>inspect.php</code>, отправьте файл через <code>curl</code> и посмотрите на два значения. Конкретный MIME-результат Fileinfo может зависеть от его базы, но он определяется по временному файлу, а не по параметру <code>type=image/jpeg</code> в команде.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
// inspect.php
|
||||
$file = $_FILES['avatar'] ?? [];
|
||||
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
exit('Файл не получен');
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$detected = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
|
||||
if ($finfo) {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'type from request: ' . ($file['type'] ?? '-') . PHP_EOL;
|
||||
echo 'type from Fileinfo: ' . ($detected ?: '-') . PHP_EOL;
|
||||
`),
|
||||
codeBlock(String.raw`
|
||||
printf '<html>это не фотография</html>' > /tmp/not-an-image.txt
|
||||
php -S 127.0.0.1:8080
|
||||
|
||||
curl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \
|
||||
http://127.0.0.1:8080/inspect.php
|
||||
`),
|
||||
paragraph('Такой опыт не доказывает, что Fileinfo распознает все форматы без ошибок. Он доказывает более скромную вещь: строка <code>$file["type"]</code> описывает заявление отправителя, а не результат серверной проверки. Этого уже достаточно, чтобы убрать её из условия допуска.'),
|
||||
heading('Функция, которая возвращает только полезный контракт'),
|
||||
paragraph('После опыта можно свести проверку к небольшому контракту. Функция ниже не переносит файл и не создаёт запись в базе. Она отвечает только на вопрос, можно ли передать временный файл следующему шагу, и возвращает значение, которое тот шаг действительно использует.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
|
||||
function inspectImageUpload(array $file): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('Загрузка не завершилась');
|
||||
}
|
||||
|
||||
if (!isset($file['tmp_name'], $file['size']) || (int)$file['size'] > 2097152) {
|
||||
throw new RuntimeException('Размер файла недопустим');
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo === false) {
|
||||
throw new RuntimeException('Fileinfo недоступен');
|
||||
}
|
||||
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
$extensions = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
];
|
||||
|
||||
if (!is_string($mime) || !isset($extensions[$mime])) {
|
||||
throw new RuntimeException('Допустимы только JPEG и PNG');
|
||||
}
|
||||
|
||||
return [
|
||||
'temporaryPath' => $file['tmp_name'],
|
||||
'mime' => $mime,
|
||||
'extension' => $extensions[$mime],
|
||||
'bytes' => (int)$file['size'],
|
||||
];
|
||||
}
|
||||
`),
|
||||
heading('Почему это не «одна проверка вместо всех»'),
|
||||
paragraph('Fileinfo отвечает на вопрос о типе, но не о праве пользователя загружать файл, не о свободном месте и не о том, можно ли безопасно разбирать этот формат дополнительной библиотекой. В нашем случае разрешены только две картинки, поэтому белый список короткий. Если продукту нужны PDF, архивы и таблицы, лучше не расширять тот же массив до десятка значений, а сделать отдельные маршруты с отдельными лимитами и правилами выдачи.'),
|
||||
paragraph('Расширение всё ещё может быть полезным для интерфейса: по нему браузер открывает фильтр выбора, а пользователь понимает, какой файл выбрал. Но серверный ключ и расширение результата лучше строить из решения приложения: Fileinfo вернул <code>image/png</code> — приложение выбирает <code>.png</code>. Так имя не способно незаметно поменять путь или ожидаемый обработчик.'),
|
||||
heading('Последовательность проверки'),
|
||||
orderedList([
|
||||
'Проверить код <code>UPLOAD_ERR_*</code> и остановиться до чтения временного файла при любой ошибке.',
|
||||
'Проверить размер, потому что допустимый тип не отменяет ограничение на место и время обработки.',
|
||||
'Определить MIME-тип через Fileinfo и сравнить его с белым списком именно этого сценария.',
|
||||
'Если нужны размеры, прочитать их после проверки типа и трактовать как требование интерфейса, а не как сертификат безопасности.',
|
||||
'Передать следующему слою только сгенерированный ключ, серверный MIME-тип и нужные метаданные; клиентское имя оставить за пределами файлового пути.',
|
||||
]),
|
||||
heading('Ограничения'),
|
||||
paragraph('Пример не является антивирусом и не делает опасный формат безопасным. Он также не ограничивает размер всего HTTP-запроса на уровне веб-сервера и PHP-конфигурации. Это нужно проверять отдельно: прикладной лимит защищает логику, а ограничения окружения — сам приём запроса. Если затем файл отдаётся другим пользователям, появляется ещё один самостоятельный вопрос: кто и по какому маршруту его читает.'),
|
||||
heading('Проверяемые источники'),
|
||||
sourceList([multipartRfc, phpUploadErrors, phpFileinfo, phpGetImageSize, owaspUpload]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
const fieldArticle = {
|
||||
slug: 'editorial-2018-03-field-safe-uploads',
|
||||
title: 'PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой',
|
||||
categories: ['PHP', 'Безопасность'],
|
||||
cover: '/assets/editorial/2018/php-private-download-flow.svg',
|
||||
excerpt: 'Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.',
|
||||
readingMinutes: 9,
|
||||
contentHtml: [
|
||||
paragraph('Симптом: личный документ открывается по прямому URL из <code>/uploads</code> без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в <code>/uploads</code>, а ссылка стала чем-то вроде <code>/uploads/ivan-passport.pdf</code>. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.'),
|
||||
paragraph('Здесь разбираю один вопрос: <strong>как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня?</strong> Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.'),
|
||||
heading('У файла должны быть две разные сущности'),
|
||||
paragraph('Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением <code>.pdf</code>. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.'),
|
||||
figure(
|
||||
'/assets/editorial/2018/php-private-download-flow.svg',
|
||||
'Схема приватной выдачи: запрос к маршруту проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ.',
|
||||
'Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.',
|
||||
),
|
||||
dataTable(
|
||||
['Слой', 'Что в нём храним', 'Чего в нём нет'],
|
||||
[
|
||||
['Таблица <code>documents</code>', '<code>id</code>, <code>owner_id</code>, <code>storage_key</code>, статус', 'Публичного URL и пути, собранного из имени пользователя'],
|
||||
['Закрытый каталог', 'Файл по ключу, созданному приложением', 'Оригинального имени и логики авторизации'],
|
||||
['Маршрут <code>/documents/{id}/download</code>', 'Проверку текущего пользователя и HTTP-ответ', 'Свободного параметра <code>path</code> из запроса'],
|
||||
['Браузер', 'Содержимое файла после успешного ответа', 'Сведений о расположении файла на сервере'],
|
||||
],
|
||||
),
|
||||
heading('Небольшой обработчик PDF'),
|
||||
paragraph('Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в <code>Content-Disposition</code> тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.'),
|
||||
codeBlock(String.raw`
|
||||
<?php
|
||||
|
||||
function sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void
|
||||
{
|
||||
$query = $pdo->prepare(
|
||||
'SELECT storage_key
|
||||
FROM documents
|
||||
WHERE id = :id AND owner_id = :owner_id AND status = :status'
|
||||
);
|
||||
$query->execute([
|
||||
':id' => $documentId,
|
||||
':owner_id' => $currentUserId,
|
||||
':status' => 'ready',
|
||||
]);
|
||||
$document = $query->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$document) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$key = (string)$document['storage_key'];
|
||||
if (!preg_match('/\\A[a-f0-9]{32}\\.pdf\\z/', $key)) {
|
||||
error_log('Некорректный ключ документа ' . $documentId);
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$path = '/var/app/private-uploads/' . $key;
|
||||
if (!is_file($path)) {
|
||||
error_log('Не найден файл для документа ' . $documentId);
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: attachment; filename="document.pdf"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
`),
|
||||
paragraph('SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым <code>404</code>; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.'),
|
||||
heading('Как воспроизвести проверку'),
|
||||
paragraph('На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом <code>ready</code> и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.'),
|
||||
orderedList([
|
||||
'Анна запрашивает <code>/documents/42/download</code>: получает <code>200</code>, заголовок <code>Content-Type: application/pdf</code> и байты тестового файла.',
|
||||
'Борис запрашивает тот же URL: получает <code>404</code>, а тело файла не попадает в ответ.',
|
||||
'Запрос к предполагаемому пути <code>/uploads/<storage_key></code> не должен находить файл, потому что каталог не лежит в веб-корне.',
|
||||
'Удаляем файл на диске при сохранённой записи: получаем <code>404</code> и запись в серверном журнале без абсолютного пути в ответе пользователю.',
|
||||
'Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.',
|
||||
]),
|
||||
heading('Что будет, если оставить прямую ссылку'),
|
||||
paragraph('Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.'),
|
||||
heading('Ограничения этого решения'),
|
||||
paragraph('У <code>readfile</code> простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.'),
|
||||
paragraph('Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что <code>header()</code> вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного <code>echo</code> раньше заголовков.'),
|
||||
heading('Проверяемые источники'),
|
||||
sourceList([owaspUpload, phpHeader, phpReadfile, contentDispositionRfc]),
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||
|
||||
if (process.argv[1]?.endsWith('/upgrade-2018-03.mjs')) {
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
} else {
|
||||
process.stderr.write('Usage: node scripts/upgrade-2018-03.mjs --print-revisions\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const articlesPath = join(webRoot, 'data', 'articles.json');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function textFromHtml(html) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const translit = {
|
||||
title: 'Bitrix: CUtil::translit',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php',
|
||||
note: 'параметры нормализации строки: регистр, замена пробелов и повторяющихся разделителей',
|
||||
};
|
||||
|
||||
const addElement = {
|
||||
title: 'Bitrix: CIBlockElement::Add',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y',
|
||||
note: 'создание элемента, поле CODE, возвращаемый ID и LAST_ERROR при ошибке',
|
||||
};
|
||||
|
||||
const getList = {
|
||||
title: 'Bitrix: CIBlockElement::GetList',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y',
|
||||
note: 'выборка элементов по фильтрам IBLOCK_ID, CODE, ACTIVE и с заданным порядком',
|
||||
};
|
||||
|
||||
const updateElement = {
|
||||
title: 'Bitrix: CIBlockElement::Update',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
|
||||
note: 'изменение полей существующего элемента и результат операции',
|
||||
};
|
||||
|
||||
const parseComponentPath = {
|
||||
title: 'Bitrix: CComponentEngine::ParseComponentPath',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php',
|
||||
note: 'разбор ЧПУ-пути по шаблонам и восстановление переменных компонента',
|
||||
};
|
||||
|
||||
const makePathFromTemplate = {
|
||||
title: 'Bitrix: CComponentEngine::MakePathFromTemplate',
|
||||
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/makepathfromtemplate.php',
|
||||
note: 'подстановка значений массива в маркеры URL-шаблона',
|
||||
};
|
||||
|
||||
const drafts = [
|
||||
{
|
||||
slug: 'editorial-2018-04-practice-bitrix-slugs',
|
||||
title: 'Bitrix API. Символьный код: как не получить два одинаковых адреса',
|
||||
categories: ['Bitrix', 'PHP', 'Практика'],
|
||||
cover: '/assets/editorial/2018/bitrix-slug-build-2018.svg',
|
||||
excerpt: 'Собираем символьный код элемента из имени, проверяем занятость в нужном инфоблоке и разбираем границу, за которой простой суффикс перестаёт быть защитой.',
|
||||
readingMinutes: 10,
|
||||
sources: [translit, getList, addElement],
|
||||
bodyHtml: [
|
||||
paragraph('Добавляем товар в Bitrix и берём <code>CODE</code> из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?'),
|
||||
paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'),
|
||||
heading('Что даёт системный транслит'),
|
||||
paragraph('В Bitrix для этой задачи есть <code>CUtil::translit</code>. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'),
|
||||
paragraph('Но нормализация не делает два разных названия разными. «Кофе Classic 250 г», «Кофе Classic-250 г» и «Кофе Classic 250 г» вполне могут прийти к одному кандидату. Это не ошибка <code>CUtil::translit</code>. Функция честно выполнила свою работу: привела вход к одному виду. Сравнивать и разрешать конфликт должен вызывающий код.'),
|
||||
figure('/assets/editorial/2018/bitrix-slug-build-2018.svg', 'Схема построения символьного кода: имя, транслитерация, проверка через GetList, суффикс или создание элемента', 'Транслит формирует кандидата. Решение о свободном коде появляется только после проверки в нужном инфоблоке.'),
|
||||
heading('Минимальный контракт'),
|
||||
paragraph('Для одного каталога достаточно договориться о нескольких вещах до написания функции. Они не привязаны к шаблону страницы и не требуют большой переделки. Зато по ним сразу видно, почему повторный импорт изменил адрес или почему карточка попала не в тот раздел.'),
|
||||
dataTable(
|
||||
['Шаг', 'Что считаем результатом', 'Что проверяем'],
|
||||
[
|
||||
['Имя', 'Есть непустое название', 'Не передаём в транслит пустую строку и не придумываем код из ID молча'],
|
||||
['Нормализация', 'Один предсказуемый кандидат', 'Регистр, дефис, длина и повторяющиеся разделители заданы явно'],
|
||||
['Поиск', 'Нет элемента с тем же CODE', 'Ищем внутри конкретного <code>IBLOCK_ID</code>, а не по всему сайту'],
|
||||
['Сохранение', 'Метод Add вернул ID', 'При ошибке сохраняем <code>LAST_ERROR</code> и исходное имя'],
|
||||
['Проверка ссылки', 'Каталог находит именно эту запись', 'Сверяем URL-шаблон и фильтр детального компонента'],
|
||||
],
|
||||
),
|
||||
heading('Воспроизводимый пример'),
|
||||
paragraph('Ниже функция для последовательного добавления из админки или небольшого импорта. Число 50 здесь не ограничение Bitrix, а мой предел для понятной ошибки: если за пятьдесят попыток не найден свободный вариант, лучше остановиться и посмотреть на входные данные. В реальном проекте ID инфоблока и правило суффикса стоит вынести в конфигурацию.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'function getFreeElementCode($iblockId, $name)',
|
||||
'{',
|
||||
' $base = CUtil::translit(trim($name), "ru", array(',
|
||||
' "max_len" => 90,',
|
||||
' "change_case" => "L",',
|
||||
' "replace_space" => "-",',
|
||||
' "replace_other" => "-",',
|
||||
' "delete_repeat_replace" => true,',
|
||||
' ));',
|
||||
'',
|
||||
' $base = trim($base, "-");',
|
||||
' if ($base === "") {',
|
||||
' throw new InvalidArgumentException("Не удалось получить CODE из NAME");',
|
||||
' }',
|
||||
'',
|
||||
' for ($number = 1; $number <= 50; $number++) {',
|
||||
' $candidate = $number === 1 ? $base : $base . "-" . $number;',
|
||||
' $result = CIBlockElement::GetList(',
|
||||
' array(),',
|
||||
' array("IBLOCK_ID" => (int)$iblockId, "=CODE" => $candidate),',
|
||||
' false,',
|
||||
' array("nTopCount" => 1),',
|
||||
' array("ID")',
|
||||
' );',
|
||||
'',
|
||||
' if (!$result->Fetch()) {',
|
||||
' return $candidate;',
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
' throw new RuntimeException("Не найден свободный CODE за 50 попыток");',
|
||||
'}',
|
||||
]),
|
||||
paragraph('Знак <code>=</code> в фильтре делает намерение явным: мы ищем конкретный код, а не похожую строку. В выборку достаточно взять <code>ID</code>; имя, картинка и свойства для решения о занятости не нужны. Это маленькая деталь, но она не даёт диагностическому запросу превращаться в выборку всего каталога.'),
|
||||
heading('Сохраняем код вместе с элементом'),
|
||||
paragraph('После проверки не нужно делать отдельный <code>Update</code> ради <code>CODE</code>. Документация <code>CIBlockElement::Add</code> допускает поле <code>CODE</code> в массиве полей. Добавляю его в тот же вызов и обязательно разбираю ошибку. Возвращённый ID доказывает запись, но ещё не доказывает, что путь компонента совпадает с проектным URL.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$element = new CIBlockElement();',
|
||||
'$id = $element->Add(array(',
|
||||
' "IBLOCK_ID" => 12,',
|
||||
' "NAME" => $name,',
|
||||
' "CODE" => getFreeElementCode(12, $name),',
|
||||
' "ACTIVE" => "N",',
|
||||
'));',
|
||||
'',
|
||||
'if ($id === false) {',
|
||||
' throw new RuntimeException($element->LAST_ERROR);',
|
||||
'}',
|
||||
'',
|
||||
'// Публикуем только после проверки обязательных данных и ссылки.',
|
||||
]),
|
||||
heading('Последовательность проверки'),
|
||||
orderedList([
|
||||
'Взять два названия, которые различаются только знаками и пробелами, и получить для них кандидаты.',
|
||||
'Создать первый элемент на тестовом инфоблоке с исходным кандидатом.',
|
||||
'Запустить функцию для второго имени и убедиться, что она вернула суффикс, а не прежний код.',
|
||||
'Прочитать оба элемента через <code>CIBlockElement::GetList</code> с тем же <code>IBLOCK_ID</code>.',
|
||||
'Открыть детальные страницы и сверить ID в шаблоне или временном логе. Так мы проверяем не только данные, но и используемый компонентом маршрут.',
|
||||
]),
|
||||
heading('Граница этого решения'),
|
||||
paragraph('Проверка «сначала <code>GetList</code>, потом <code>Add</code>» не является атомарной. Два параллельных воркера могут одновременно увидеть свободный код и попытаться сохранить одинаковое значение. Для ручного ввода и последовательного импорта этого обычно достаточно. Для параллельной синхронизации нужен отдельный проектный механизм: очередь, блокировка или код, связанный со стабильным внешним идентификатором. Какой именно — зависит от версии Bitrix, базы и требований к существующим URL.'),
|
||||
paragraph('Не стоит лечить эту задачу случайным числом в каждом коде. Такой адрес перестаёт быть повторяемым при повторном импорте, а диагностика становится сложнее. Если данные поставщика имеют стабильный артикул, полезно заранее решить, будет ли он участвовать в <code>CODE</code> или останется отдельным свойством. Главное — зафиксировать правило до публикации первой тысячи карточек.'),
|
||||
heading('Итог'),
|
||||
paragraph('Символьный код начинается с <code>CUtil::translit</code>, но не заканчивается на нём. Сначала делаем читаемого кандидата, затем проверяем его в нужном инфоблоке, сохраняем результат вместе с элементом и отдельно открываем ссылку. Такой порядок не решает гонку параллельного импорта, зато честно показывает её границу и избавляет от тихих совпадений в обычной работе.'),
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
slug: 'editorial-2018-04-mechanism-bitrix-slugs',
|
||||
title: 'Bitrix API. Как адрес каталога превращается в ELEMENT_CODE',
|
||||
categories: ['Bitrix', 'PHP', 'ЧПУ'],
|
||||
cover: '/assets/editorial/2018/bitrix-slug-route-2018.svg',
|
||||
excerpt: 'Разбираем, где ЧПУ-путь становится переменной компонента, почему URL-шаблон не равен запросу к инфоблоку и как проверить связку без гадания по кешу.',
|
||||
readingMinutes: 10,
|
||||
sources: [parseComponentPath, makePathFromTemplate, getList],
|
||||
bodyHtml: [
|
||||
paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением <code>ELEMENT_CODE</code>?'),
|
||||
paragraph('Это полезно отделить в голове. Адрес <code>/catalog/kofe/classic-250-g/</code> не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка <code>CODE</code>, хотя ошибка сидит в шаблоне или в имени переменной.'),
|
||||
heading('Что делает движок ЧПУ'),
|
||||
paragraph('В документации <code>CComponentEngine::ParseComponentPath</code> описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в <code>ELEMENT_CODE</code>.'),
|
||||
paragraph('Шаблон пишется относительно папки компонента. Например, для папки <code>/catalog/</code> внутри массива нужен путь <code>#SECTION_CODE#/#ELEMENT_CODE#/</code>, а не полный адрес с начальным слешем. Это не вкусовщина: документация отдельно предупреждает, что лишний слеш в шаблоне меняет результат разбора.'),
|
||||
figure('/assets/editorial/2018/bitrix-slug-route-2018.svg', 'Схема: запрос браузера разбирается SEF-шаблоном, превращается в SECTION_CODE и ELEMENT_CODE, затем используется в выборке', 'Переменная из URL и элемент инфоблока живут на разных шагах. Между ними стоит проектный фильтр компонента.'),
|
||||
heading('Четыре значения, которые должны совпасть'),
|
||||
dataTable(
|
||||
['Участок', 'Пример', 'Как проверить'],
|
||||
[
|
||||
['Папка ЧПУ', '<code>/catalog/</code>', 'Сравнить с <code>SEF_FOLDER</code> вызванного компонента'],
|
||||
['Шаблон детали', '<code>#SECTION_CODE#/#ELEMENT_CODE#/</code>', 'Проверить отсутствие лишнего начального слеша и нужные маркеры'],
|
||||
['Переменная', '<code>ELEMENT_CODE = classic-250-g</code>', 'Вывести массив, полученный после разбора, на тестовой среде'],
|
||||
['Выборка', '<code>IBLOCK_ID + CODE + ACTIVE</code>', 'Сравнить фильтр компонента с контрольным <code>GetList</code>'],
|
||||
['Ссылка в шаблоне', 'Тот же набор маркеров', 'Собрать URL из значений и открыть его вручную'],
|
||||
],
|
||||
),
|
||||
heading('Минимальный воспроизводимый разбор'),
|
||||
paragraph('Ниже не готовый комплексный компонент, а короткая проверка его основания. Запускаю её на тестовой странице с известным путём. Если <code>$page</code> не равен <code>detail</code>, до запроса к инфоблоку дело вообще не дошло. Если код страницы найден, но <code>ELEMENT_CODE</code> пуст, виноват шаблон или сам адрес.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'CModule::IncludeModule("iblock");',
|
||||
'',
|
||||
'$arUrlTemplates = array(',
|
||||
' "detail" => "#SECTION_CODE#/#ELEMENT_CODE#/",',
|
||||
');',
|
||||
'$arVariables = array();',
|
||||
'',
|
||||
'$page = CComponentEngine::ParseComponentPath(',
|
||||
' "/catalog/",',
|
||||
' $arUrlTemplates,',
|
||||
' $arVariables,',
|
||||
' "/catalog/kofe/classic-250-g/"',
|
||||
');',
|
||||
'',
|
||||
'if ($page !== "detail" || empty($arVariables["ELEMENT_CODE"])) {',
|
||||
' throw new RuntimeException("URL не разобран как детальная страница");',
|
||||
'}',
|
||||
'',
|
||||
'$result = CIBlockElement::GetList(',
|
||||
' array(),',
|
||||
' array(',
|
||||
' "IBLOCK_ID" => 12,',
|
||||
' "=CODE" => $arVariables["ELEMENT_CODE"],',
|
||||
' "ACTIVE" => "Y",',
|
||||
' ),',
|
||||
' false,',
|
||||
' array("nTopCount" => 1),',
|
||||
' array("ID", "NAME", "CODE")',
|
||||
');',
|
||||
'',
|
||||
'$element = $result->GetNext();',
|
||||
'if (!$element) {',
|
||||
' throw new RuntimeException("URL разобран, но элемент не найден");',
|
||||
'}',
|
||||
]),
|
||||
paragraph('В примере я специально оставил фильтр небольшим. Реальный каталог может добавить раздел, права, цену, наличие или свойство витрины. Эти условия нельзя угадывать из адреса. Их нужно взять из конкретного компонента и применить в контрольной выборке. Иначе тест будет доказывать только то, что элемент вообще существует, а не то, что его видит пользователь.'),
|
||||
heading('Почему генерация и разбор должны пользоваться одной формой адреса'),
|
||||
paragraph('Метод <code>CComponentEngine::MakePathFromTemplate</code> подставляет значения массива в маркеры шаблона. Это удобная точка для проверки обратного направления: у нас есть <code>SECTION_CODE</code> и <code>ELEMENT_CODE</code>, собираем путь и затем разбираем его тем же шаблоном. Если после такого круга переменная изменилась или пропала, в коде сайта уже есть расхождение.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$url = CComponentEngine::MakePathFromTemplate(',
|
||||
' "#SECTION_CODE#/#ELEMENT_CODE#/",',
|
||||
' array(',
|
||||
' "SECTION_CODE" => "kofe",',
|
||||
' "ELEMENT_CODE" => "classic-250-g",',
|
||||
' )',
|
||||
');',
|
||||
'',
|
||||
'// $url: kofe/classic-250-g/',
|
||||
'// Для ссылки добавляем папку /catalog/ в одном месте проекта.',
|
||||
]),
|
||||
heading('Последовательность от ссылки до карточки'),
|
||||
orderedList([
|
||||
'Взять реальный адрес, который не открывается, и сохранить его без ручной правки.',
|
||||
'Сверить папку и шаблон детали в параметрах вызванного компонента.',
|
||||
'На тестовой среде вывести код страницы и массив переменных после <code>ParseComponentPath</code>.',
|
||||
'Передать полученный <code>ELEMENT_CODE</code> в короткий <code>CIBlockElement::GetList</code> с теми же базовыми фильтрами.',
|
||||
'Если элемент найден, сравнить с фильтром самого компонента: раздел, активность, права и проектные свойства.',
|
||||
'Собрать обратную ссылку из тех же маркеров и повторить проверку после изменения шаблона.',
|
||||
]),
|
||||
heading('Частые расхождения'),
|
||||
dataTable(
|
||||
['Симптом', 'Где искать', 'Безопасная проверка'],
|
||||
[
|
||||
['Страница не определяется', 'Папка ЧПУ или шаблон детали', 'Проверить результат <code>ParseComponentPath</code> до обращения к инфоблоку'],
|
||||
['Страница определяется, код пуст', 'Маркер отличается от имени, которое ждёт компонент', 'Сравнить ключи массива переменных с параметрами компонента'],
|
||||
['Код есть, элемента нет', 'CODE, инфоблок, активность или дополнительный фильтр', 'Запустить <code>GetList</code> сначала с базовыми, затем с проектными условиями'],
|
||||
['Ссылка формируется иначе, чем разбирается', 'Два разных URL-шаблона в шаблоне и компоненте', 'Собрать путь через <code>MakePathFromTemplate</code> и разобрать его обратно'],
|
||||
],
|
||||
),
|
||||
heading('Ограничения'),
|
||||
paragraph('Эта диагностика начинается в момент, когда PHP-компонент уже получил запрос. Если веб-сервер или правила перенаправления не передали путь в приложение, <code>ParseComponentPath</code> не сможет это исправить. Тогда проверять нужно предыдущий слой: фактический URI, правило маршрутизации и точку входа сайта. Не стоит менять <code>CODE</code>, пока не доказано, что компонент вообще получил нужную переменную.'),
|
||||
paragraph('Ещё одна ловушка — перенос чужого шаблона без понимания его маркеров. В Bitrix можно назвать переменные по-разному, но компонент и его фильтр должны читать то же имя, которое восстановлено из пути. Я бы не делал универсальную функцию для всех страниц сайта: лучше зафиксировать один шаблон рядом с конкретным каталогом и покрыть его двумя-тремя адресами из реальных данных.'),
|
||||
heading('Итог'),
|
||||
paragraph('ЧПУ — это не «красивый CODE в базе», а связка из папки, шаблона, восстановленных переменных и фильтра элемента. Когда ссылка ведёт в 404, сначала смотрим результат разбора URL, затем выборку. После такой проверки становится видно, нужна ли правка в данных, компоненте или маршруте.'),
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
slug: 'editorial-2018-04-field-bitrix-slugs',
|
||||
title: 'Bitrix API. Карточка открывает не тот товар: проверяем конфликт CODE',
|
||||
categories: ['Bitrix', 'PHP', 'Диагностика'],
|
||||
cover: '/assets/editorial/2018/bitrix-slug-conflict-2018.svg',
|
||||
excerpt: 'Полевой разбор ситуации, когда адрес детали показывает другой элемент: считаем совпадения по CODE, сравниваем переменную ЧПУ с фильтром и меняем данные без потери следов.',
|
||||
readingMinutes: 11,
|
||||
sources: [getList, parseComponentPath, updateElement],
|
||||
bodyHtml: [
|
||||
paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт <code>CODE</code> или широкий фильтр до того, как менять данные?'),
|
||||
paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'),
|
||||
heading('Не путать симптом и причину'),
|
||||
paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому <code>ELEMENT_CODE</code>.'),
|
||||
figure('/assets/editorial/2018/bitrix-slug-conflict-2018.svg', 'Дерево диагностики неправильной карточки: путь, переменная ELEMENT_CODE, число совпадений GetList и дальнейшие действия', 'Сначала считаем набор совпадений. Кеш проверяем только после пути и данных.'),
|
||||
heading('Какие данные собрать до исправления'),
|
||||
dataTable(
|
||||
['Факт', 'Зачем он нужен', 'Как зафиксировать'],
|
||||
[
|
||||
['Исходный URL', 'Показывает, что реально запросил браузер', 'Сохранить полный путь из адресной строки или access-лога'],
|
||||
['Ожидаемый ID', 'Не даёт спорить о том, какая запись считается правильной', 'Взять ID из админки или из результата импорта'],
|
||||
['ELEMENT_CODE', 'Связывает путь с данными компонента', 'Вывести переменную после разбора ЧПУ на тестовом стенде'],
|
||||
['Все записи по CODE', 'Отличает один результат от конфликта', 'Сделать ограниченный <code>GetList</code> в том же инфоблоке'],
|
||||
['Фильтр детали', 'Объясняет, почему часть записей исключена или выбрана', 'Сверить с параметрами и кодом конкретного компонента'],
|
||||
],
|
||||
),
|
||||
heading('Контрольная выборка'),
|
||||
paragraph('Документация <code>CIBlockElement::GetList</code> позволяет явно задать сортировку, фильтр, ограничение и набор полей. Для диагностики беру только те поля, которые помогают отличить записи: ID, имя, CODE, основной раздел и шаблон детального URL. Запрос не должен случайно тянуть свойства всего каталога: его задача — показать размер набора и порядок элементов.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'function findActiveElementsByCode($iblockId, $code)',
|
||||
'{',
|
||||
' $result = CIBlockElement::GetList(',
|
||||
' array("ID" => "ASC"),',
|
||||
' array(',
|
||||
' "IBLOCK_ID" => (int)$iblockId,',
|
||||
' "=CODE" => $code,',
|
||||
' "ACTIVE" => "Y",',
|
||||
' ),',
|
||||
' false,',
|
||||
' array("nTopCount" => 20),',
|
||||
' array("ID", "NAME", "CODE", "IBLOCK_SECTION_ID", "DETAIL_PAGE_URL")',
|
||||
' );',
|
||||
'',
|
||||
' $items = array();',
|
||||
' while ($item = $result->GetNext()) {',
|
||||
' $items[] = $item;',
|
||||
' }',
|
||||
'',
|
||||
' return $items;',
|
||||
'}',
|
||||
'',
|
||||
'$items = findActiveElementsByCode(12, "classic-250-g");',
|
||||
'if (count($items) !== 1) {',
|
||||
' throw new RuntimeException("Нужно разобрать " . count($items) . " совпадений");',
|
||||
'}',
|
||||
]),
|
||||
paragraph('Сортировка по ID в этом примере нужна не для выбора «правильного» товара, а для повторяемого вывода. Если там два элемента, проблема уже доказана: детальный компонент не должен случайно решать, что меньший ID важнее. Дальше либо сужаем фильтр контекстом раздела, либо исправляем один из кодов по заранее выбранному правилу.'),
|
||||
heading('Как отличить три разных случая'),
|
||||
dataTable(
|
||||
['Результат проверки', 'Что это значит', 'Следующий шаг'],
|
||||
[
|
||||
['0 совпадений', 'URL разобран, но элемент не проходит базовый фильтр', 'Проверить значение переменной, активность, инфоблок и шаблон ссылки'],
|
||||
['1 совпадение, ID правильный', 'Данные и базовый фильтр совпали', 'Сравнить дополнительные условия компонента и только затем кеш'],
|
||||
['1 совпадение, ID другой', 'Переменная из URL не соответствует ожидаемому товару', 'Проверить генерацию URL, шаблон и исходный CODE элемента'],
|
||||
['2 и более совпадений', 'Фильтр недостаточно точный или коды конфликтуют', 'Решить, нужен ли контекст раздела, затем изменить конфликтующие данные'],
|
||||
],
|
||||
),
|
||||
heading('Проверяем, что компонент получил из URL'),
|
||||
paragraph('Не нужно угадать имя переменной по шаблону. Комплексный компонент разбирает путь через <code>CComponentEngine::ParseComponentPath</code> и возвращает переменные, восстановленные из маркеров. Для проблемной ссылки полезно на тестовой копии вывести <code>$page</code> и <code>$arVariables</code>. Так видно, не потерялся ли раздел и действительно ли <code>ELEMENT_CODE</code> равен строке из адреса.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$templates = array(',
|
||||
' "detail" => "#SECTION_CODE#/#ELEMENT_CODE#/",',
|
||||
');',
|
||||
'$variables = array();',
|
||||
'$page = CComponentEngine::ParseComponentPath(',
|
||||
' "/catalog/",',
|
||||
' $templates,',
|
||||
' $variables,',
|
||||
' "/catalog/kofe/classic-250-g/"',
|
||||
');',
|
||||
'',
|
||||
'if ($page !== "detail") {',
|
||||
' throw new RuntimeException("Не найден шаблон detail");',
|
||||
'}',
|
||||
'',
|
||||
'error_log(print_r($variables, true));',
|
||||
]),
|
||||
paragraph('Если в массиве нет <code>SECTION_CODE</code>, а детальный запрос должен учитывать раздел, коды элементов могут быть вполне корректны. Ошибка будет в URL-шаблоне или в логике компонента, который не применяет восстановленную переменную. И наоборот: если переменные верны, а <code>GetList</code> возвращает несколько записей, искать надо в данных и условиях выборки, не в роутинге.'),
|
||||
heading('Исправление без потери истории'),
|
||||
paragraph('Когда конфликт подтверждён, сначала выбираю правило для нового адреса: суффикс, артикул или раздел. Затем сохраняю старый URL и список мест, которые на него ссылаются. Смена <code>CODE</code> меняет адрес, поэтому публикацию лучше выполнять отдельным шагом с проверкой ссылок. Метод <code>CIBlockElement::Update</code> возвращает результат изменения; при ошибке не пропускаем <code>LAST_ERROR</code>.'),
|
||||
codeBlock([
|
||||
'<?php',
|
||||
'',
|
||||
'$element = new CIBlockElement();',
|
||||
'$updated = $element->Update($duplicateId, array(',
|
||||
' "CODE" => "classic-250-g-2",',
|
||||
'));',
|
||||
'',
|
||||
'if (!$updated) {',
|
||||
' throw new RuntimeException($element->LAST_ERROR);',
|
||||
'}',
|
||||
'',
|
||||
'// После изменения снова выполняем findActiveElementsByCode().',
|
||||
]),
|
||||
heading('Порядок работы в продовой задаче'),
|
||||
orderedList([
|
||||
'Зафиксировать URL, ожидаемый ID и время, когда ошибка наблюдалась.',
|
||||
'На тестовой копии получить переменные, восстановленные из того же пути.',
|
||||
'Сделать выборку по фактическому <code>CODE</code> в нужном <code>IBLOCK_ID</code> и посчитать результаты.',
|
||||
'Сравнить полученные ID с тем, что показывает детальный компонент после его дополнительных фильтров.',
|
||||
'Если есть конфликт, выбрать новое стабильное правило кода и проверить все старые ссылки, которые важны для проекта.',
|
||||
'После изменения повторить URL-проверку. Кеш и индекс обновлять только по принятому в проекте порядку, когда данные и маршрут уже доказаны.',
|
||||
]),
|
||||
heading('Ограничения'),
|
||||
paragraph('Эта заметка не утверждает, что любое совпадение <code>CODE</code> ошибочно. В некоторых каталогах один и тот же код допустим в разных витринах или разделах, и тогда адрес и фильтр обязаны включать этот контекст. Не следует добавлять раздел в запрос автоматически: сначала нужно понять, что именно считает идентичностью текущий компонент.'),
|
||||
paragraph('Также не стоит менять десятки кодов одной SQL-командой. У Bitrix есть API изменения элемента, обработчики событий и проектные зависимости от адресов. Сначала правим один доказанный конфликт на тестовых данных, проверяем маршрут и только потом составляем отдельный план для массовой миграции.'),
|
||||
heading('Итог'),
|
||||
paragraph('Когда адрес открывает не тот товар, удобнее не спорить о кеше, а посчитать факты. URL даёт переменную, переменная даёт набор элементов, набор показывает — это маршрут, фильтр или конфликт данных. После такой проверки изменение <code>CODE</code> становится осознанной операцией, а не попыткой наугад исправить карточку.'),
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||
const archiveSlugs = new Set(archive.map((article) => article.slug));
|
||||
const requiredSlugs = [
|
||||
'editorial-2018-04-practice-bitrix-slugs',
|
||||
'editorial-2018-04-mechanism-bitrix-slugs',
|
||||
'editorial-2018-04-field-bitrix-slugs',
|
||||
];
|
||||
|
||||
for (const slug of requiredSlugs) {
|
||||
if (!archiveSlugs.has(slug)) {
|
||||
throw new Error('Article not found: ' + slug);
|
||||
}
|
||||
}
|
||||
|
||||
const reports = drafts.map((draft) => {
|
||||
const bodyLength = textFromHtml(draft.bodyHtml).length;
|
||||
const sourceCount = draft.sources.length;
|
||||
|
||||
if (bodyLength < 5000 || bodyLength > 15000) {
|
||||
throw new Error('Body length outside 5,000–15,000 characters: ' + draft.slug + ' (' + bodyLength + ')');
|
||||
}
|
||||
if (sourceCount < 2) {
|
||||
throw new Error('At least two primary sources are required: ' + draft.slug);
|
||||
}
|
||||
if (!draft.bodyHtml.includes('<figure>') || !draft.bodyHtml.includes('<table>') || !draft.bodyHtml.includes('<pre><code>')) {
|
||||
throw new Error('Figure, table or reproducible code is missing: ' + draft.slug);
|
||||
}
|
||||
|
||||
return {
|
||||
slug: draft.slug,
|
||||
bodyCharacters: bodyLength,
|
||||
sourceCount,
|
||||
hasFigure: true,
|
||||
hasTable: true,
|
||||
hasCode: true,
|
||||
};
|
||||
});
|
||||
|
||||
const revisions = drafts.map((draft) => {
|
||||
const { bodyHtml, sources, ...revision } = draft;
|
||||
return {
|
||||
...revision,
|
||||
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
|
||||
};
|
||||
});
|
||||
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
console.log(JSON.stringify(revisions, null, 2));
|
||||
} else if (process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(reports, null, 2));
|
||||
} else {
|
||||
console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(code) {
|
||||
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function bulletList(items) {
|
||||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function visibleText(html) {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function createRevision(meta, bodyParts, sources) {
|
||||
const bodyHtml = bodyParts.join('\n');
|
||||
const bodyLength = visibleText(bodyHtml).length;
|
||||
|
||||
if (bodyLength < 5000 || bodyLength > 15000) {
|
||||
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
|
||||
}
|
||||
|
||||
const contentHtml = [
|
||||
bodyHtml,
|
||||
heading('Проверяемые источники'),
|
||||
sourceList(sources),
|
||||
].join('\n');
|
||||
|
||||
for (const requiredFragment of ['<figure>', '<table>', '<pre><code>']) {
|
||||
if (!contentHtml.includes(requiredFragment)) {
|
||||
throw new Error(meta.slug + ': missing required fragment ' + requiredFragment);
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.length < 2) {
|
||||
throw new Error(meta.slug + ': at least two primary sources are required');
|
||||
}
|
||||
|
||||
return {
|
||||
...meta,
|
||||
contentHtml,
|
||||
bodyLength,
|
||||
};
|
||||
}
|
||||
|
||||
const jqueryOn = {
|
||||
title: 'jQuery API: .on()',
|
||||
url: 'https://api.jquery.com/on/',
|
||||
note: 'прямая и делегированная привязка, пространства имён, повторная привязка и ограничения делегирования',
|
||||
};
|
||||
|
||||
const jqueryOff = {
|
||||
title: 'jQuery API: .off()',
|
||||
url: 'https://api.jquery.com/off/',
|
||||
note: 'снятие обработчика по типу события, селектору и пространству имён',
|
||||
};
|
||||
|
||||
const jqueryHtml = {
|
||||
title: 'jQuery API: .html()',
|
||||
url: 'https://api.jquery.com/html/',
|
||||
note: 'замена содержимого, удаление событий дочерних узлов и риск вставки непроверенной HTML-строки',
|
||||
};
|
||||
|
||||
const jqueryAjax = {
|
||||
title: 'jQuery API: jQuery.ajax()',
|
||||
url: 'https://api.jquery.com/jQuery.ajax/',
|
||||
note: 'jqXHR, обработчики done/fail/always, timeout и порядок завершения запроса',
|
||||
};
|
||||
|
||||
const jquerySerialize = {
|
||||
title: 'jQuery API: .serialize()',
|
||||
url: 'https://api.jquery.com/serialize/',
|
||||
note: 'какие поля формы попадают в URL-кодированную строку и почему файлы в неё не входят',
|
||||
};
|
||||
|
||||
const jqueryProp = {
|
||||
title: 'jQuery API: .prop()',
|
||||
url: 'https://api.jquery.com/prop/',
|
||||
note: 'динамические свойства disabled и checked в jQuery 1.6+',
|
||||
};
|
||||
|
||||
const jqueryData = {
|
||||
title: 'jQuery API: .data()',
|
||||
url: 'https://api.jquery.com/data/',
|
||||
note: 'хранение состояния рядом с DOM-узлом',
|
||||
};
|
||||
|
||||
const jqueryRemoveData = {
|
||||
title: 'jQuery API: .removeData()',
|
||||
url: 'https://api.jquery.com/removeData/',
|
||||
note: 'удаление ранее сохранённого значения из внутреннего хранилища jQuery',
|
||||
};
|
||||
|
||||
const jqueryAlways = {
|
||||
title: 'jQuery API: deferred.always()',
|
||||
url: 'https://api.jquery.com/deferred.always/',
|
||||
note: 'обработчик, который вызывается и после resolve, и после reject; подходит для освобождения интерфейса',
|
||||
};
|
||||
|
||||
const practiceArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2018-05-practice-legacy-jquery',
|
||||
title: 'jQuery. Как повторно инициализировать виджет и не получить два клика',
|
||||
categories: ['JavaScript', 'jQuery', 'Практика'],
|
||||
cover: '/assets/editorial/2018/jquery-reinit-namespaces.svg',
|
||||
excerpt: 'Разбираем маленький контракт для legacy-виджета: повторный mount снимает только свои события, назначает один обработчик и проверяется тремя вызовами подряд.',
|
||||
readingMinutes: 9,
|
||||
},
|
||||
[
|
||||
paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем <code>mountOrderForm</code>, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Ошибка неприятна не из-за консоли: одна пользовательская команда может несколько раз изменить состояние на сервере.'),
|
||||
paragraph('Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции <code>mount</code> и проверить его в браузере.'),
|
||||
heading('Почему обработчик умножается'),
|
||||
paragraph('Метод <code>.on()</code> привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.'),
|
||||
paragraph('Плохой вариант обычно выглядит безобидно. Его легко не заметить, когда страница открывается один раз и только руками.'),
|
||||
codeBlock(String.raw`
|
||||
function mountOrderForm() {
|
||||
$('.js-order-submit').on('click', function (event) {
|
||||
event.preventDefault();
|
||||
sendOrder();
|
||||
});
|
||||
}
|
||||
|
||||
mountOrderForm();
|
||||
mountOrderForm(); // теперь у каждой найденной кнопки два обработчика
|
||||
`),
|
||||
paragraph('Не надо лечить это глобальным <code>off("click")</code>. Такой вызов снимет и события соседнего кода, который может не иметь отношения к форме. Сначала нужно дать событиям нашего виджета собственное имя. В jQuery пространство имён не является иерархией, но позволяет снять обработчики по имени, не трогая чужие <code>click</code>-события.'),
|
||||
figure('/assets/editorial/2018/jquery-reinit-namespaces.svg', 'Три шага повторной инициализации jQuery-виджета: снять обработчики с пространством имён, затем назначить один новый', 'Повторный вызов mount сначала очищает только события конкретного виджета, затем создаёт один обработчик.'),
|
||||
heading('Контракт функции mount'),
|
||||
paragraph('Для этого примера договоримся о трёх вещах. Контейнер <code>#order-panel</code> существует до вызова функции. Все события виджета получают пространство имён <code>.orderForm</code>. После выполнения функции у контейнера есть ровно один делегированный обработчик для кнопки отправки. Такая формулировка важнее названия функции: по ней можно проверить результат и не спорить о том, достаточно ли «аккуратно» написан код.'),
|
||||
dataTable(
|
||||
['Условие', 'Действие mount', 'Ожидаемый результат', 'Чего не делаем'],
|
||||
[
|
||||
['Контейнер уже есть в DOM', 'Работаем от <code>#order-panel</code>', 'Есть стабильная граница виджета', 'Не ищем кнопку по всему документу'],
|
||||
['mount вызван повторно', 'Снимаем <code>.orderForm</code> с контейнера', 'Старый обработчик виджета исчезает', 'Не вызываем <code>off("click")</code>'],
|
||||
['Кнопка появилась позже', 'Используем селектор во втором аргументе <code>.on()</code>', 'Клик новой кнопки доходит до контейнера', 'Не перепривязываем всё дерево после каждой мелочи'],
|
||||
['Соседний код слушает click', 'Оставляем чужое пространство имён нетронутым', 'Другой модуль продолжает работать', 'Не полагаемся на порядок загрузки скриптов'],
|
||||
],
|
||||
),
|
||||
heading('Рабочий пример'),
|
||||
paragraph('В коде ниже обработчик висит на постоянном контейнере, а не на самой кнопке. Это небольшое делегирование: jQuery проверит, что событие пришло от потомка с классом <code>.js-order-submit</code>. Подробно о том, почему это полезно при замене разметки, поговорим в следующей заметке; здесь важно другое — перед новым <code>.on()</code> мы удаляем только обработчики нашей зоны.'),
|
||||
codeBlock(String.raw`
|
||||
(function ($) {
|
||||
var eventNamespace = '.orderForm';
|
||||
|
||||
function sendOrder($button) {
|
||||
// В проекте здесь будет Ajax-вызов или событие в общий слой.
|
||||
window.console.count('order request');
|
||||
$button.addClass('is-pending');
|
||||
}
|
||||
|
||||
function mountOrderForm(root) {
|
||||
var $root = $(root);
|
||||
|
||||
if ($root.length !== 1) {
|
||||
throw new Error('Нужен один контейнер формы заказа');
|
||||
}
|
||||
|
||||
$root.off(eventNamespace);
|
||||
$root.on('click' + eventNamespace, '.js-order-submit', function (event) {
|
||||
event.preventDefault();
|
||||
sendOrder($(this));
|
||||
});
|
||||
}
|
||||
|
||||
window.mountOrderForm = mountOrderForm;
|
||||
}(jQuery));
|
||||
|
||||
mountOrderForm('#order-panel');
|
||||
`),
|
||||
paragraph('Вызов <code>$root.off(eventNamespace)</code> затрагивает все события с пространством <code>.orderForm</code> на этом контейнере. Это удобно, когда у виджета несколько собственных событий: например, <code>click.orderForm</code> и <code>change.orderForm</code>. Но имя должно быть достаточно конкретным. Если два независимых скрипта выберут одно и то же <code>.form</code>, они начнут снимать события друг друга.'),
|
||||
heading('Воспроизводимая проверка без сервера'),
|
||||
paragraph('Не нужно ждать настоящего API, чтобы увидеть дефект. В консоли страницы можно собрать короткий счётчик и трижды вызвать тестовый mount. Если после одного программного клика счётчик равен единице, контракт выполнен. Если он равен трём, проблема остаётся на фронтенде и сервер здесь пока ни при чём.'),
|
||||
codeBlock(String.raw`
|
||||
var calls = 0;
|
||||
var $panel = $('<div id="order-panel"><a class="js-order-submit" href="#">Оформить</a></div>');
|
||||
|
||||
function mountDemo(root) {
|
||||
var $root = $(root);
|
||||
|
||||
$root.off('.demoOrder');
|
||||
$root.on('click.demoOrder', '.js-order-submit', function (event) {
|
||||
event.preventDefault();
|
||||
calls += 1;
|
||||
});
|
||||
}
|
||||
|
||||
$('body').append($panel);
|
||||
mountDemo('#order-panel');
|
||||
mountDemo('#order-panel');
|
||||
mountDemo('#order-panel');
|
||||
|
||||
$panel.find('.js-order-submit').trigger('click');
|
||||
window.console.assert(calls === 1, 'Нужен один обработчик, получено: ' + calls);
|
||||
|
||||
$panel.remove();
|
||||
`),
|
||||
paragraph('В рабочем проекте вместо подмены <code>console.count</code> полезнее вынести обработчик в именованную функцию и проверить количество вызовов тестом. Но даже такой короткий сценарий дисциплинирует: он проверяет не внешний вид кнопки, а свойство инициализации при повторном запуске.'),
|
||||
heading('Когда вызывать mount'),
|
||||
paragraph('Я бы вызывал функцию в двух местах: после начальной загрузки страницы и после того кода, который действительно заменил или добавил разметку внутри <code>#order-panel</code>. Не нужно размещать вызов в каждом Ajax-обработчике приложения «на всякий случай». Чем меньше мест создают виджет, тем проще понять, почему он существует на странице.'),
|
||||
paragraph('Если обновление заменяет сам <code>#order-panel</code>, старый контейнер вместе со своими событиями уйдёт из DOM. Тогда нужно передать в <code>mountOrderForm</code> уже новый контейнер после вставки. Если же постоянным остаётся внешний блок, лучше выбрать его корнем и менять только внутреннюю разметку. Это решение не универсально: оно зависит от того, какой узел реально переживает обновление.'),
|
||||
heading('Последовательность внедрения'),
|
||||
orderedList([
|
||||
'Найти функцию, которая сейчас повторно вешает события, и назвать один постоянный контейнер виджета.',
|
||||
'Выбрать уникальное пространство имён, например <code>.orderForm</code> или <code>.cartItem</code>, а не общее <code>.click</code>.',
|
||||
'Перед каждым назначением вызвать <code>off</code> только для этого пространства имён на выбранном контейнере.',
|
||||
'Назначить обработчик через <code>on</code> и, если кнопки меняются, передать селектор потомка.',
|
||||
'Трижды вызвать mount и одним кликом подтвердить, что полезное действие срабатывает один раз.',
|
||||
'Отдельно проверить реальный серверный сценарий: клиентская защита не должна быть единственным барьером повторной операции.',
|
||||
]),
|
||||
heading('Ограничения'),
|
||||
bulletList([
|
||||
'Этот приём требует jQuery 1.7 или новее, потому что использует <code>.on()</code> и <code>.off()</code>. Если проект закреплён на более старой версии, сначала надо зафиксировать допустимый путь обновления или отдельный совместимый адаптер.',
|
||||
'Пространство имён защищает только события в браузере. Оно не отменяет уже отправленный запрос и не делает серверную операцию безопасной при повторе страницы, таймауте или ручном запросе.',
|
||||
'Делегирование работает для событий, которые доходят до выбранного предка. Для особых типов событий и SVG у jQuery есть ограничения; их надо проверять по документации, а не переносить этот шаблон вслепую.',
|
||||
'Если виджет начинает управлять десятком независимых состояний, одного обработчика уже мало. Сначала стоит разделить маленькие функции, а не превращать <code>mountOrderForm</code> в глобальный диспетчер.',
|
||||
]),
|
||||
heading('Итог'),
|
||||
paragraph('Повторная инициализация не обязана быть опасной. Ей нужен простой договор: устойчивый корень, собственное пространство имён и проверка «несколько mount — один клик». Этот договор легко показать коллеге, а при следующей Ajax-правке не придётся угадывать, сколько обработчиков уже живёт на кнопке.'),
|
||||
],
|
||||
[jqueryOn, jqueryOff],
|
||||
);
|
||||
|
||||
const mechanismArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2018-05-mechanism-legacy-jquery',
|
||||
title: 'jQuery. Почему кнопка перестаёт работать после .html()',
|
||||
categories: ['JavaScript', 'jQuery', 'DOM'],
|
||||
cover: '/assets/editorial/2018/jquery-delegation-after-html.svg',
|
||||
excerpt: 'Разбираем, почему прямой обработчик исчезает вместе с заменённой разметкой, как выбрать устойчивый контейнер для делегирования и где этот приём не подходит.',
|
||||
readingMinutes: 9,
|
||||
},
|
||||
[
|
||||
paragraph('Каталог отрисовал новую страницу товаров через Ajax: <code>#products</code> получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться.'),
|
||||
paragraph('Главный вопрос этой заметки: почему обработчик пропадает после <code>.html()</code> и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.'),
|
||||
heading('Что делает .html() с прежней разметкой'),
|
||||
paragraph('Когда <code>.html(строка)</code> задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.'),
|
||||
paragraph('В этом легко убедиться на коротком примере. Сначала обработчик привязан непосредственно к найденной кнопке. После замены HTML в контейнере новая кнопка появляется без этого обработчика.'),
|
||||
codeBlock(String.raw`
|
||||
var $products = $('#products');
|
||||
|
||||
function buy(event) {
|
||||
event.preventDefault();
|
||||
window.console.log('Товар добавлен');
|
||||
}
|
||||
|
||||
$products.find('.js-buy').on('click', buy);
|
||||
|
||||
$products.html('<a class="js-buy" href="/cart/add/17">Купить</a>');
|
||||
|
||||
// Эта новая ссылка создана после .on(), поэтому buy для неё не назначен.
|
||||
$products.find('.js-buy').trigger('click');
|
||||
`),
|
||||
paragraph('Это не повод каждый раз обходить все кнопки после рендера. Прямая привязка нормальна, когда элемент стабилен и событие относится только к нему. Но в списке, который полностью перерисовывается, она делает жизненный цикл события зависимым от каждой вставки HTML. Такую зависимость лучше перенести на постоянный контейнер.'),
|
||||
figure('/assets/editorial/2018/jquery-delegation-after-html.svg', 'Сравнение прямого обработчика на кнопке и делегированного обработчика на устойчивом контейнере после замены HTML', 'Прямой обработчик уходит вместе со старой кнопкой. Делегированный остаётся на контейнере и получает клик от новой дочерней кнопки.'),
|
||||
heading('Прямая привязка и делегирование — это разные владельцы'),
|
||||
paragraph('У <code>.on()</code> без селектора обработчик привязан к текущему набору элементов. Если передать селектор вторым аргументом, обработчик остаётся на выбранном предке и вызывается, когда событие всплывает от подходящего потомка. Документация jQuery называет эти варианты direct и delegated. Для нашего каталога владелец события должен быть не карточкой, а <code>#products</code>, если этот блок не заменяется целиком.'),
|
||||
dataTable(
|
||||
['Подход', 'Где хранится обработчик', 'Что случится после .html()', 'Подходит для'],
|
||||
[
|
||||
['Прямой <code>$(".js-buy").on(...)</code>', 'На найденных кнопках', 'Старые узлы удалены, новым кнопкам нужен новый bind', 'Стабильная одиночная кнопка или плагин, которому нужен именно элемент'],
|
||||
['Делегированный <code>$root.on(..., ".js-buy", ...)</code>', 'На постоянном <code>$root</code>', 'Новая кнопка под тем же корнем начинает работать сразу', 'Карточки, строки таблицы, пункты меню, которые заменяются'],
|
||||
['На <code>document</code>', 'На самом верхнем доступном узле', 'Технически может пережить почти любую замену', 'Только когда ближнего постоянного контейнера действительно нет'],
|
||||
],
|
||||
),
|
||||
heading('Исправление на устойчивом контейнере'),
|
||||
paragraph('Выберем ближайший узел, который существует до и после обновления списка. Здесь это <code>#products</code>. Перед назначением снимем только своё пространство имён: так повторная инициализация не будет плодить обработчики, а соседние click-события останутся на месте.'),
|
||||
codeBlock(String.raw`
|
||||
(function ($) {
|
||||
function addToCart(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var $link = $(this);
|
||||
var productId = $link.data('product-id');
|
||||
|
||||
if (!productId) {
|
||||
window.console.warn('У кнопки нет product-id');
|
||||
return;
|
||||
}
|
||||
|
||||
window.console.log('Добавляем товар ' + productId);
|
||||
}
|
||||
|
||||
function mountProductList(root) {
|
||||
var $root = $(root);
|
||||
|
||||
$root.off('.productList');
|
||||
$root.on('click.productList', '.js-buy', addToCart);
|
||||
}
|
||||
|
||||
window.mountProductList = mountProductList;
|
||||
}(jQuery));
|
||||
|
||||
mountProductList('#products');
|
||||
`),
|
||||
paragraph('Теперь серверный ответ может заменить внутренности <code>#products</code>, а обработчик остаётся на самом контейнере. Он увидит клик, который всплывёт от новой ссылки и совпадёт с селектором <code>.js-buy</code>. Если проект меняет и сам <code>#products</code>, этот код не сделает чудо: нужно вызвать <code>mountProductList</code> для нового контейнера или выбрать более внешний, но всё ещё локальный корень.'),
|
||||
heading('Проверяем разметку и событие по отдельности'),
|
||||
paragraph('В legacy-проекте легко перепутать три причины: Ajax вернул не ту разметку, селектор не совпал или событие не дошло до корня. Поэтому я бы проверял их раздельно. Сначала подменяю HTML статической строкой, затем запускаю программный click, и только после этого возвращаю реальный запрос. Так сетевой сбой не маскирует ошибку жизненного цикла DOM.'),
|
||||
codeBlock(String.raw`
|
||||
var calls = 0;
|
||||
var $root = $('<div id="products"><a class="js-buy" data-product-id="17" href="#">Купить</a></div>');
|
||||
|
||||
$('body').append($root);
|
||||
|
||||
$root.off('.demo');
|
||||
$root.on('click.demo', '.js-buy', function (event) {
|
||||
event.preventDefault();
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
$root.html('<a class="js-buy" data-product-id="18" href="#">Купить другую</a>');
|
||||
$root.find('.js-buy').trigger('click');
|
||||
|
||||
window.console.assert(calls === 1, 'Делегированный click должен дойти до корня');
|
||||
$root.remove();
|
||||
`),
|
||||
paragraph('Если проверка не проходит, сначала смотрим на корень: он существует в момент вызова <code>.on()</code>, внутри него действительно лежит новая кнопка, и её класс совпадает с селектором? Затем проверяем тип события. В документации jQuery есть важные исключения: делегированные обработчики не работают для SVG, а некоторые события не всплывают. Для таких случаев нельзя механически переносить click-шаблон.'),
|
||||
heading('Почему document — не первая точка'),
|
||||
paragraph('У <code>document</code> есть соблазнительное свойство: он почти всегда живёт дольше виджета. Но документация jQuery советует выбирать место как можно ближе к целевым элементам. На большой странице делегирование высокочастотных событий сверху заставляет jQuery сравнивать селекторы по длинному пути всплытия. Для click на небольшом участке разница может быть незаметна, но архитектурно всё равно лучше, когда каталог слушает каталог, а не весь сайт.'),
|
||||
paragraph('Есть и практическая причина. Локальный корень показывает границу ответственности: код карточек не должен случайно перехватить похожую кнопку в модальном окне или в шапке. Селектор <code>.js-buy</code> становится понятным только в контексте <code>#products</code>.'),
|
||||
heading('Отдельный риск: строка HTML — это не безопасные данные'),
|
||||
paragraph('У <code>.html()</code> есть ещё один неприятный край. Документация jQuery предупреждает, что методы, принимающие HTML-строку, потенциально выполняют код из вставленных тегов или атрибутов. Поэтому в пример выше строка попала только как тестовая разметка, написанная в исходнике. Нельзя передавать в <code>.html()</code> необработанный параметр URL, текст из формы или поле API, если сервер не гарантирует его безопасное формирование.'),
|
||||
heading('Порядок исправления'),
|
||||
orderedList([
|
||||
'Найти точный вызов <code>.html()</code> или другой код, который заменяет дочерние карточки.',
|
||||
'Проверить, какой ближайший контейнер не заменяется при обновлении.',
|
||||
'Снять со стабильного контейнера только события конкретного виджета по пространству имён.',
|
||||
'Назначить делегированный обработчик с простым селектором потомка.',
|
||||
'Подменить разметку тестовой строкой и вызвать click программно, чтобы отделить DOM-проблему от сети.',
|
||||
'Вернуть реальный Ajax и отдельно проверить, что HTML приходит из доверенного источника и соответствует ожидаемому контракту.',
|
||||
]),
|
||||
heading('Ограничения'),
|
||||
bulletList([
|
||||
'Делегирование не заменяет прямую привязку во всех случаях. Если нужен обработчик на самом элементе плагина или событие не всплывает, придётся выбрать другой контракт.',
|
||||
'По документации jQuery делегированные обработчики не работают для SVG. Для интерактивных SVG нельзя рассчитывать на этот пример без отдельной проверки.',
|
||||
'Слишком общий корень и тяжёлый селектор могут создать лишнюю работу при частых событиях. Выбираем ближайший живой контейнер и простую границу.',
|
||||
'Починка click не решает вопрос повторной серверной операции. Контракт формы и запросов нужно проверять отдельно.',
|
||||
]),
|
||||
heading('Итог'),
|
||||
paragraph('После <code>.html()</code> новая кнопка — это новый DOM-узел без старого прямого обработчика. Делегирование решает ровно эту задачу, если обработчик живёт на устойчивом и близком контейнере. Когда мы называем владельца события и проверяем замену разметки отдельно от Ajax, исчезает и необходимость в случайных повторных bind.'),
|
||||
],
|
||||
[jqueryHtml, jqueryOn, jqueryOff, jqueryData],
|
||||
);
|
||||
|
||||
const fieldArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2018-05-field-legacy-jquery',
|
||||
title: 'jQuery. Как не отправить legacy-форму дважды',
|
||||
categories: ['JavaScript', 'jQuery', 'Ajax'],
|
||||
cover: '/assets/editorial/2018/jquery-ajax-form-contract.svg',
|
||||
excerpt: 'Практический контракт Ajax-формы: один активный jqXHR, корректная сериализация, явные ветки успеха и ошибки, обязательное освобождение кнопки.',
|
||||
readingMinutes: 10,
|
||||
},
|
||||
[
|
||||
paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области.'),
|
||||
paragraph('Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.'),
|
||||
heading('Сначала определим, что именно отправляет форма'),
|
||||
paragraph('Метод <code>.serialize()</code> строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть <code>name</code>, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через <code>.serialize()</code> не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.'),
|
||||
paragraph('Я предпочитаю сериализовать сам <code><form></code>, а не объединять вручную все <code>input</code> на странице. Так не появляется дубль, когда в выборку по ошибке попали и форма, и её дочерние поля. Этот нюанс прямо описан в документации jQuery.'),
|
||||
dataTable(
|
||||
['Состояние формы', 'Что хранится на форме', 'Что видит пользователь', 'Следующий переход'],
|
||||
[
|
||||
['Готова', 'Ключ <code>orderRequest</code> отсутствует', 'Кнопка доступна', 'submit создаёт один jqXHR'],
|
||||
['Запрос идёт', 'В <code>.data()</code> лежит маркер или jqXHR', 'Кнопка отключена', 'Повторный submit сразу выходит'],
|
||||
['Успех', 'Сервер вернул ожидаемый ответ', 'Показываем подтверждённый результат', 'always освобождает интерфейс'],
|
||||
['Ошибка или timeout', 'jqXHR отклонён', 'Показываем понятную ошибку', 'always освобождает интерфейс'],
|
||||
],
|
||||
),
|
||||
figure('/assets/editorial/2018/jquery-ajax-form-contract.svg', 'Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем обязательное освобождение интерфейса', 'Ветка успеха и ветка ошибки разные, а освобождение кнопки живёт в always и не зависит от параметров ответа.'),
|
||||
heading('Минимальная разметка и граница обработчика'),
|
||||
paragraph('Пусть форма существует на странице постоянно. Скрытый токен и поля уже выдаёт сервер; пример не придумывает их значение. Важно только, чтобы каждое отправляемое поле имело имя, а кнопка была внутри формы.'),
|
||||
codeBlock(String.raw`
|
||||
<form id="order-form" action="/order/create" method="post">
|
||||
<input type="hidden" name="csrf_token" value="серверное_значение">
|
||||
<label>
|
||||
Почта
|
||||
<input name="email" type="email" required>
|
||||
</label>
|
||||
<label>
|
||||
<input name="agree" type="checkbox" value="Y">
|
||||
Согласен с условиями
|
||||
</label>
|
||||
<button type="submit">Оформить</button>
|
||||
<p class="js-order-message" aria-live="polite"></p>
|
||||
</form>
|
||||
`),
|
||||
paragraph('Клиентский замок я храню через <code>.data()</code> на самой форме. Это локально: на странице с двумя независимыми формами их состояния не смешаются. Для кнопки использую <code>.prop("disabled", true)</code>, а не <code>.attr</code>, потому что <code>disabled</code> — динамическое свойство DOM; jQuery отдельно рекомендует <code>.prop()</code> для <code>disabled</code> и <code>checked</code>.'),
|
||||
heading('Рабочий обработчик'),
|
||||
paragraph('Пример написан для jQuery 3.x и использует <code>done</code>, <code>fail</code> и <code>always</code> у объекта <code>jqXHR</code>. Метод <code>$.ajax()</code> возвращает jqXHR с Promise-интерфейсом. В <code>done</code> мы разбираем ответ, в <code>fail</code> — транспортную ошибку, а в <code>always</code> выполняем действие, которому не нужны параметры ответа: освобождаем форму.'),
|
||||
codeBlock(String.raw`
|
||||
(function ($) {
|
||||
var requestKey = 'orderRequest';
|
||||
|
||||
function showMessage($form, text, isError) {
|
||||
$form.find('.js-order-message')
|
||||
.toggleClass('is-error', isError)
|
||||
.text(text);
|
||||
}
|
||||
|
||||
function unlock($form, $button) {
|
||||
$form.removeData(requestKey);
|
||||
$button.prop('disabled', false);
|
||||
}
|
||||
|
||||
function submitOrder(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var $form = $(this);
|
||||
var $button = $form.find('[type="submit"]');
|
||||
|
||||
if ($form.data(requestKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$form.data(requestKey, true);
|
||||
$button.prop('disabled', true);
|
||||
showMessage($form, 'Отправляем…', false);
|
||||
|
||||
var request;
|
||||
|
||||
try {
|
||||
request = $.ajax({
|
||||
url: $form.attr('action'),
|
||||
type: $form.attr('method') || 'POST',
|
||||
data: $form.serialize(),
|
||||
dataType: 'json',
|
||||
timeout: 10000
|
||||
});
|
||||
} catch (error) {
|
||||
unlock($form, $button);
|
||||
showMessage($form, 'Не удалось начать запрос', true);
|
||||
return;
|
||||
}
|
||||
|
||||
$form.data(requestKey, request);
|
||||
|
||||
request
|
||||
.done(function (response) {
|
||||
if (!response || response.ok !== true || typeof response.orderNumber === 'undefined') {
|
||||
showMessage($form, 'Сервер не подтвердил оформление', true);
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage($form, 'Заказ принят: ' + response.orderNumber, false);
|
||||
})
|
||||
.fail(function (xhr, status) {
|
||||
var text = status === 'timeout'
|
||||
? 'Сервер не ответил вовремя. Проверьте статус заказа перед повтором.'
|
||||
: 'Не удалось отправить форму. Попробуйте позже.';
|
||||
|
||||
showMessage($form, text, true);
|
||||
})
|
||||
.always(function () {
|
||||
unlock($form, $button);
|
||||
});
|
||||
}
|
||||
|
||||
$('#order-form')
|
||||
.off('submit.orderForm')
|
||||
.on('submit.orderForm', submitOrder);
|
||||
}(jQuery));
|
||||
`),
|
||||
paragraph('Маркер <code>true</code> записывается до старта Ajax. После успешного создания jqXHR он заменяется на сам объект запроса: это удобно для отладки в консоли, но в примере не используется для отмены. Если <code>$.ajax()</code> не удалось начать синхронно, блок <code>catch</code> снимает маркер и возвращает кнопку. В обычном сетевом отказе код пойдёт через <code>fail</code>, а <code>always</code> всё равно вернёт форму к начальному состоянию.'),
|
||||
heading('Что именно проверяет этот код'),
|
||||
paragraph('Первая защита — обработчик <code>submit</code>, а не только click на кнопке. Поэтому Enter в поле проходит тем же путём. Вторая защита — состояние на форме. Если тот же submit придёт, пока есть маркер, функция выходит без второго <code>$.ajax()</code>. Третья — переключение кнопки. Оно даёт пользователю видимый сигнал и уменьшает шанс случайного повторного действия, но не является единственным условием корректности.'),
|
||||
codeBlock(String.raw`
|
||||
// Временный диагностический крючок для staging:
|
||||
var sent = 0;
|
||||
var originalAjax = $.ajax;
|
||||
|
||||
$.ajax = function () {
|
||||
sent += 1;
|
||||
return originalAjax.apply(this, arguments);
|
||||
};
|
||||
|
||||
$('#order-form').trigger('submit');
|
||||
$('#order-form').trigger('submit');
|
||||
|
||||
window.console.assert(sent === 1, 'Форма не должна запускать второй Ajax до завершения первого');
|
||||
`),
|
||||
paragraph('Такую подмену не надо оставлять в production. Она нужна, чтобы коротко воспроизвести контракт: два submit подряд должны создать один Ajax-вызов. Для реального теста вместо неё лучше замокать endpoint или проверять запросы в браузерном тесте. Но если счётчик сразу показывает два вызова, искать ошибку на сервере ещё рано.'),
|
||||
heading('Почему success не равен завершению интерфейса'),
|
||||
paragraph('Иногда старый код разблокирует кнопку только в callback успеха. Тогда при timeout, 500 или ошибке сети пользователь остаётся с выключенной формой и обновляет страницу. У jqXHR есть <code>done</code>, <code>fail</code> и <code>always</code>; документация jQuery рекомендует не анализировать аргументы в <code>always</code>, потому что при resolve и reject они различаются. Это как раз подходящее место для одинакового действия: убрать локальный маркер и вернуть кнопку.'),
|
||||
paragraph('Успешный HTTP-ответ тоже не обязательно означает, что операция готова. В примере договор сервера требует <code>response.ok === true</code> и номер заказа. Если API проекта отвечает иначе, нужно описать именно его контракт: какие поля обязательны, где лежит текст ошибки, можно ли повторить запрос и когда результат считается подтверждённым. Не стоит считать успехом любой JSON только потому, что запрос завершился без сетевой ошибки.'),
|
||||
heading('Последовательность внедрения'),
|
||||
orderedList([
|
||||
'Открыть текущую форму в браузере и зафиксировать фактический URL, метод, поля и ожидаемый ответ API.',
|
||||
'Проверить, что необходимые поля имеют <code>name</code>; отдельно решить, как отправляются файлы, потому что <code>.serialize()</code> их не включает.',
|
||||
'Перевести обработку на <code>submit</code> и снять только прежнее событие формы через уникальное пространство имён.',
|
||||
'Записать маркер до отправки, выключить кнопку через <code>.prop()</code> и создать один jqXHR.',
|
||||
'Разделить подтверждённый бизнес-ответ, ошибку транспорта и общее освобождение интерфейса.',
|
||||
'Проверить два submit подряд, timeout и ответ API с ошибкой; после каждого сценария форма должна либо показать результат, либо снова стать доступной.',
|
||||
]),
|
||||
heading('Ограничения'),
|
||||
bulletList([
|
||||
'Клиентский маркер существует только в текущем DOM. Обновление страницы, второй браузер, ручный HTTP-запрос или повтор после timeout могут создать новый запрос. Критичная операция должна быть защищена на сервере по правилам конкретного домена.',
|
||||
'В примере нет загрузки файлов. Документация jQuery указывает, что file input не сериализуется через <code>.serialize()</code>; для него нужен отдельный согласованный транспорт.',
|
||||
'Не показываем номер заказа из любого произвольного ответа. Формат <code>ok</code> и <code>orderNumber</code> — пример контракта, который сервер должен подтвердить.',
|
||||
'Timeout — это отсутствие ответа за выбранный интервал, а не доказательство, что сервер ничего не сделал. Поэтому текст ошибки не обещает безопасный повтор, пока проект не определил проверку статуса операции.',
|
||||
]),
|
||||
heading('Итог'),
|
||||
paragraph('У legacy Ajax-формы должно быть немного состояний и ни одного скрытого перехода: формы нет в запросе, форма ждёт один jqXHR, затем показывает подтверждённый результат или ошибку и в любом случае освобождает интерфейс. Такой код не решает серверную идемпотентность, но перестаёт создавать собственные дубли и даёт читабельную точку для следующей диагностики.'),
|
||||
],
|
||||
[jqueryAjax, jquerySerialize, jqueryProp, jqueryData, jqueryRemoveData, jqueryAlways],
|
||||
);
|
||||
|
||||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
|
||||
.map(({ bodyLength, ...revision }) => revision);
|
||||
|
||||
if (process.argv[1]?.endsWith('/upgrade-2018-05.mjs')) {
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
} else {
|
||||
process.stderr.write('Usage: node web/scripts/upgrade-2018-05.mjs --print-revisions\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||