.NET Разработчик
6.51K subscribers
427 photos
2 videos
14 files
2.04K links
Дневник сертифицированного .NET разработчика.

Для связи: @SBenzenko

Поддержать канал:
- https://boosty.to/netdeveloperdiary
- https://patreon.com/user?u=52551826
- https://pay.cloudtips.ru/p/70df3b3b
Download Telegram
День 2259. #ЗаметкиНаПолях
Почему GraphQL? Начало
Недавно мы коротко сравнили REST с GraphQL. Теперь подробно рассмотрим, почему существует GraphQL и когда его использовать.

GraphQL (создан Facebook в 2012, код открыт в 2015) предназначен для решения распространённых проблем неэффективности REST API.

Особенности:
- Точная выборка данных: клиенты указывают, какие данные им нужны, что снижает избыточную (получение слишком большого количества данных) и недостаточную (недостаточное количество данных) выборку.
- Единая конечная точка: в отличие от REST, который часто требует нескольких запросов к разным конечным точкам, GraphQL объединяет данные из нескольких источников в одном запросе.
- Строго типизированная схема: API самодокументируются с чётко определённой схемой, которая обеспечивает стабильность и предсказуемость.
- Данные в реальном времени: поддерживает подписки на обновления в реальном времени.
- Агрегация данных: легко объединяет данные из нескольких источников в один ответ.

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

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

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

3. Агрегация и управление версиями API
Вместо того, чтобы использовать различные конечные точки REST или параметры запросов, вы можете предоставить один API GraphQL, который извлекает данные из различных сервисов. Вы можете развивать схемы, не нарушая существующих клиентов, в отличие от строгого управления версиями в REST.

4. Опыт разработчика
- Самодокументирование: схема GraphQL действует как документация, упрощая разработчикам понимание и использование API.
- Мощные инструменты: такие инструменты, как Apollo Client и GraphiQL, обеспечивают превосходный опыт разработчика, включая автоматическое завершение, подсветку ошибок и тестирование запросов.

Ключевые отличия от REST
1. Получение данных
Одна общая конечная точка, в отличие от нескольких в REST.
2. Производительность
Один запрос GraphQL для получения всех данных снижает нагрузку на сеть.
3. Кэширование
В REST встроенный механизм HTTP-кэширования. В GraphQL такого нет, но можно использовать сторонние библиотеки (Apollo, Relay).
4. Схема
Строго типизированная, самодокументирующаяся схема.
5. Порог изучения
Более высокий, чем REST из-за уникальных концепций и языка запросов.
6. Загрузка файлов
Не поддерживается нативно, как в REST, требует обходных путей.
7. Обновления в реальном времени
Встроенная поддержка через подписки, в отличие от REST, где требуются дополнительные шаги, например, WebSockets.
8. Развитие API
Схема может меняться, не нарушая клиентов. В REST требуется версионирование.

Окончание следует…

Источник:
https://dev.to/lovestaco/why-graphql-a-developer-friendly-guide-to-api-evolution-51j5
2👍14
День 2260. #ЗаметкиНаПолях
Почему GraphQL? Окончание
Начало

Схема
Схема GraphQL определяет, какие данные могут запрашивать клиенты. Она служит контрактом между клиентом и сервером, гарантируя структурированный и эффективный поиск данных. Схемы обычно пишутся с использованием языка определения схем (SDL), что делает их простыми для чтения и понимания. Схема состоит из типов объектов, каждый из которых содержит поля, определяющие, какие данные доступны, например:
type Query {
user(id: ID!): User
}

type User {
name: String
email: String
bio: String
posts(limit: Int): [Post]
followersCount: Int
}

type Post {
title: String
content: String
}

Здесь:
- Query определяет доступные запросы.
- Тип User содержит такие поля, как имя, email, посты и количество подписчиков.
- Тип Post включает заголовок и тело поста.

Операции
1. Запросы
Запросы GraphQL эквивалентны запросам REST GET — они извлекают данные, не изменяя их.

2. Мутации
Мутации позволяют клиентам создавать, обновлять или удалять данные, аналогично методам REST: POST, PUT, PATCH, DELETE. Например:
mutation {
updateUser(id: "123", bio: "Exploring GraphQL!") {
name
bio
}
}


3. Подписки
Подписки GraphQL позволяют обновлять данные в режиме реального времени с помощью WebSockets. В отличие от запросов, которые требуют ручного опроса, подписки отправляют обновления при изменении данных:
subscription {
newPost {
title
content
}
}

Это позволяет обновлять клиента без выполнения повторных запросов.

Когда использовать REST?
- Простые API: если у API простые требования к данным, простота REST может быть более подходящей.
- Потребности в кэшировании: встроенное HTTP-кэширование REST более надежно, чем сторонние решения GraphQL.
- Загрузка файлов: REST изначально поддерживает загрузку файлов, в то время как GraphQL требует дополнительной настройки.
- Устаревшие системы: если вы работаете с существующим REST API, переход на GraphQL может не стоить усилий, если на то нет веской причины.

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

Источник: https://dev.to/lovestaco/why-graphql-a-developer-friendly-guide-to-api-evolution-51j5
👍10
День 2261. #SystemDesign101
Как работает gRPC?
RPC (Remote Procedure Call – Удалённый Вызов Процедур) называется «удалённым», потому что он обеспечивает связь между удалёнными сервисами, когда они развёрнуты на разных серверах в архитектуре микросервисов. С точки зрения клиента он действует как локальный вызов функции.

На схеме выше показан поток данных для gRPC.
1. Выполняется REST-запрос от клиента. Тело запроса обычно имеет формат JSON.

2–4. Сервис заказов (клиент gRPC) получает REST-запрос, преобразует его и выполняет вызов RPC к сервису платежей. gPRC кодирует заглушку клиента в двоичный формат и отправляет её на низкоуровневый транспортный уровень.

5. gRPC отправляет пакеты по сети через HTTP2. Благодаря двоичному кодированию и сетевой оптимизации gRPC примерно в 5 раз быстрее JSON.

6–8. Сервис платежей (сервер gRPC) получает пакеты из сети, декодирует их и вызывает серверное приложение.

9–11. Результат возвращается из серверного приложения, кодируется и отправляется на транспортный уровень.

12–14. Сервис заказов получает пакеты, декодирует их и отправляет результат клиентскому приложению.

Источник: https://github.com/ByteByteGoHq/system-design-101
👍19
День 2262. #TipsAndTricks
Применяем Естественную Сортировку в PowerShell

PowerShell не предоставляет встроенного способа использовать естественную сортировку. Рассмотрим, как это можно сделать при помощи простого скрипта.

Что такое естественная сортировка?
Естественная сортировка упорядочивает строки таким образом, чтобы это было более удобно для человека. Например, естественная сортировка следующих строк:
file1.txt
file10.txt
file2.txt

выдаст:
file1.txt
file2.txt
file10.txt

Как видите, естественная сортировка упорядочивает строки на основе чисел в строке, а не лексикографического порядка символов.

Простой способ получить естественную сортировку в PowerShell — использовать командлет Sort-Object с пользовательским блоком скрипта. Блок скрипта должен возвращать значение, по которому вы хотите выполнить сортировку. В этом случае вы можете использовать регулярное выражение для извлечения числового значения из строки и дополнить его нулями, чтобы гарантировать правильную сортировку чисел. Например, строка file1.txt будет преобразована в file00001.txt. Вы можете использовать столько нулей, сколько вам нужно, чтобы гарантировать правильную сортировку чисел.
Get-ChildItem | Sort-Object { [regex]::Replace($_.Name, '\d+', { $args[0].Value.PadLeft(100) }) }


Кстати, возможность естественной сортировки строк появится в .NET 10 с помощью нового компаратора строк.

Источник: https://www.meziantou.net/how-to-use-a-natural-sort-in-powershell.htm
👍9👎4
День 2263. #SystemDesign
Нельзя Реализовать Доставку Ровно-Один-Раз

Люди часто имеют фундаментальные заблуждения о том, как ведут себя распределённые системы. Например, в распределённой системе вы не можете иметь доставку сообщения ровно-один-раз (exact-once). Браузер и сервер, сервер и БД, сервер и очередь сообщений - это распределённые системы. Невозможно реализовать семантику доставки exact-once ни в одной из них.

Существует три типа семантики доставки:
- максимум-раз (at-most-once),
- хотя-бы-раз (at-least-once)
- только-раз (exact-once).
Первые два осуществимы и широко используются.

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

Атомарные протоколы вещания гарантируют надёжную и упорядоченную доставку сообщений. Но мы не можем доставлять сообщения надёжно и упорядоченно из-за разрывов сети и сбоев без высокой степени координации. Эта координация имеет свою цену (задержка и доступность), при этом всё ещё полагаясь на семантику at-least-once.

Репликация состояний стейт-машины — хороший пример. Изменения состояния идемпотентны, и многократное применение одного и того же изменения состояния не приводит к несоответствиям, пока порядок применения соответствует порядку доставки. Т.е. гарантия семантики at-least-once достаточна и упрощает реализацию. Но, если у наших сообщений есть побочные эффекты, всё пропало.

Есть несколько вариантов отправки подтверждения получателем:
1. Перед обработкой
Отправитель получает подтверждение, и удаляет (отмечает доставленным) сообщение. Но, если получатель выходит из строя до или во время обработки, данные теряются навсегда. Это семантика доставки at-most-once.
2. После обработки
Если процесс сбоит после обработки сообщения, но до доставки подтверждения, отправитель выполнит повторную доставку. Это доставка at-least-once.

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

На практике мы достигаем доставки exact-once, подделывая её. Либо сами сообщения должны быть идемпотентными, то есть их можно применять более одного раза без неблагоприятных последствий, либо мы устраняем необходимость в идемпотентности посредством дедупликации (проверяя, получали ли мы такое сообщение ранее). Идеально - если наши сообщения не требуют строгого упорядочения.

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

Итого
Не существует такого понятия, как доставка exact-once. Мы должны выбрать меньшее из двух зол, которое в большинстве случаев является доставкой at-least-once. Это можно использовать для имитации семантики exact-once, гарантируя идемпотентность или иным образом устраняя побочные эффекты от операций.

Источник: https://bravenewgeek.com/you-cannot-have-exactly-once-delivery/
👍16👎1
День 2264. #юмор
👍25
День 2265. #УрокиРазработки
Уроки 50 Лет Разработки ПО

Урок 47. Стремитесь к тому, чтобы дефект нашли коллеги, а не покупатели


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

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

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

Разновидности ревью ПО
1. Проверка коллегой. Попросите одного из коллег просмотреть ваш код и внести предложения по улучшению или исправлению.

2. Круговое обсуждение. Передайте фрагмент своей работы нескольким коллегам и попросите каждого написать отзыв.

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

4. Командный обзор. Автор заранее передаёт продукт и вспомогательные материалы нескольким рецензентам, чтобы у них было время изучить его и обозначить любые проблемы. Во время встречи рецензенты высказывают замечания.

5. Инспекция. Наиболее формальный тип подразумевает участие нескольких персонажей: автор, ведущий (модератор), секретарь, инспектор и т.п. Лучше всего подходит для рецензирования продуктов с повышенным риском.

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

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

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

Источник: Карл Вигерс “Жемчужины Разработки”. СПб.: Питер, 2024. Глава 6.
👍14
This media is not supported in your browser
VIEW IN TELEGRAM
День 2266. #ЧтоНовенького
Анализ Использования CPU Несколькими Процессами в Visual Studio
Инструмент использования CPU и профилировщик Visual Studio теперь поддерживают анализ нескольких процессов в едином представлении об активности CPU по всем процессам.

Анализ использования CPU для приложений по нескольким процессам традиционно был сложной задачей. Выявление узких мест производительности с несколькими процессами требует ручной корреляции данных по отдельным представлениям, что тормозит работу по оптимизации. Благодаря этому улучшению Visual Studio напрямую решает эти проблемы.

Новый инструментарий теперь предлагает:
- Использование CPU по процессам: диаграммы с областями и чёткими цветными дорожками позволяют легко увидеть использование CPU по всем процессам.
- Представление нескольких процессов в подробных представлениях, таких как вызывающий/вызываемый, дерево вызовов, функции, модули и график Flame.
- Чёткая идентификация процессов: мгновенно определяйте, какие процессы потребляют больше всего ресурсов.
- Фильтр процессов: опция фильтрации графиков и подробного представления, расположена в верхнем левом углу страницы сводки, позволяет сосредоточиться на отдельных процессах, важных для вашего сеанса профилирования.
- Повышенная эффективность диагностики: быстрее выявляйте проблемы между процессами с помощью унифицированного, оптимизированного представления.

Видео в хорошем качестве

Источник: https://devblogs.microsoft.com/visualstudio/multi-process-cpu-usage-analysis-in-visual-studio/
👍9
День 2267. #SystemDesign101
Что Такое Веб-Хук?

Допустим, мы управляем сайтом электронной коммерции. Клиенты отправляют заказы в сервис заказов через API-шлюз, а потом попадают в сервис платежей службу для платёжных транзакций. Сервис платежей обращается к внешнему поставщику платёжных услуг (PSP) для завершения транзакций.

Существует два способа общения с внешним PSP.

1. Поллинг (polling)
После отправки платёжного запроса в PSP сервис платежей постоянно опрашивает PSP о статусе платежа. Рано или поздно PSP вернёт статус.
Недостатки:
- Требует ресурсов от сервиса платежей.
- Прямое взаимодействие сервиса платежей с внешним поставщиком услуг создаёт уязвимости безопасности.

2. Веб-хук (webhook)
Мы можем зарегистрировать веб-хук во внешнем сервисе. Это означает: вызовите определённый мной URL, когда у вас появятся обновления по запросу. Когда PSP завершит обработку, он сделает HTTP-запрос для обновления статуса платежа.

Платёжному сервису больше не нужно тратить ресурсы на опрос статуса платежа.

Что, если PSP не вызовет URL? Можем настроить задание для проверки статуса платежа каждый час.

Веб-хуки часто называют обратными API или push-API, потому что сервер отправляет HTTP-запросы клиенту. При использовании веб-хука нужно обратить внимание на 3 вещи:
- Разработать правильный API для вызова внешнего сервиса.
- Настроить правила в API-шлюзе из соображений безопасности.
- Зарегистрировать правильный URL обратного вызова во внешнем сервисе.

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

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

Источник:
https://github.com/ByteByteGoHq/system-design-101
👍17
День 2268. #Книги
«System Design. Подготовка к сложному интервью» (Сюй А. — СПб.: Питер, 2025).

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

Поможет вам книга Алекса Сюй «System Design». Она правильно названа «руководством», потому что призвана именно научить вас проходить практические интервью по архитектуре. Описаны общие принципы прохождения интервью: что делать по шагам, чего не делать, сколько времени это занимает и чего от вас ждут. А также представлены 11 примеров проектирования реальных систем от ограничителя трафика до ленты новостей и аналога YouTube, которые могут послужить отличной шпаргалкой на будущее, если вы таки получите работу и вам придётся что-то подобное проектировать.

Книга читается удивительно легко, изобилует картинками и схемами, поэтому материал усваивается прекрасно. Хотя базовые понятия распределённых систем всё-таки описаны довольно сжато, поэтому теорию придётся изучить отдельно. Но в конце каждой главы есть список источников (некоторые из них русскоязычные), если вы захотите углубиться в изучение какого-то понятия или инструмента.

В общем, отнесу к категории мастрид для прохождения современных техсобесов.

Кстати, книга была разобрана во втором сезоне книжного клуба DotNet.

Кроме того, недавно в открытый доступ выложили доклад Андрея и Дениса Цветцих «System Design Interview», где они также «разыгрывают» типичный собес.
👍35
День 2269. #ЧтоНовенького #CSharp14
Члены-Расширения

Типы-расширения хотели добавить ещё в .NET 9, но что-то пошло не так, и их релиз отложили. Теперь они вышли в третьем превью .NET 10. Пока поддерживаются статические методы, свойства экземпляров и статические свойства. Поддержка будет расширена в будущих превью.

Сегодня у вас может быть метод расширения вроде следующего:
public static class Extensions
{
public static IEnumerable<int>
WhereGreaterThan(
this IEnumerable<int> source,
int threshold)
=> source.Where(x => x > threshold);
}

Расширяемый класс (интерфейс) — это параметр, которому предшествует ключевое слово this. В этом случае - source. Расширения были доступны только для методов.

C# 14 вводит блоки-расширения. Это блоки с областью действия, добавляющей классу(интерфейсу)-получателю члены, которые в ней содержатся. Метод расширения WhereGreaterThan в новом синтаксисе, а также свойство-расширения IsEmpty будут выглядеть так:
public static class Extensions
{
extension(IEnumerable<int> source)
{
public IEnumerable<int>
WhereGreaterThan(int threshold)
=> source.Where(x => x > threshold);

public bool IsEmpty
=> !source.Any();
}
}

Чтобы использовать члены-расширения, просто вызовите их:
List<int> list = [1, 2, 3, 4, 5];
var large = list.WhereGreaterThan(3);
if (large.IsEmpty)
Console.WriteLine("Нет чисел >3");
else
Console.WriteLine("Есть числа >3");

Поддерживаются дженерики, а правила разрешения такие же, как и для методов расширения. Например, можно сделать WhereGreaterThan и IsEmpty дженериками:
extension<T>(IEnumerable<T> source)
where T : INumber<T>
{
public IEnumerable<T>
WhereGreaterThan(T threshold)
=> source.Where(x => x > threshold);

public bool IsEmpty
=> !source.Any();
}

Ограничение INumber<T> позволяет нам использовать оператор «больше».

Статические методы и свойства не имеют получателя, поэтому блок-расширение может использоваться без имени параметра:
extension<T>(List<T>)
{
public static List<T> Create() => [];
}


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

В будущих превью обещают больше видов расширений. А пока можете попробовать эти и оставить отзывы в обсуждении на GitHub.

Источник: https://github.com/dotnet/core/blob/main/release-notes/10.0/preview/preview3/csharp.md#extension-members
👍27
День 2270. #Карьера
Почему Синдром Самозванца - Часть Пути Каждого Разработчика?

Вы когда-нибудь чувствовали, что просто притворяетесь разработчиком? Что в любой момент кто-то может разоблачить вас и уличить в том, что вы недостаточно хороши?

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

Ежедневно забываете синтаксис, делаете ошибки так часто, как будто они входят в ваши должностные инструкции… мы все так делаем.

И как можно не делать этого?

Со всей информацией, которую мы постоянно узнаём, и бесчисленными часами, потраченными на отладку, возможно ли ожидать, что мы запомним всё?

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

Когда у вас возникают такие мысли, как «Я не знаю, имеет ли смысл то, что я делаю» или «Я чувствую, что застрял на этой проблеме» выберите практичный подход. Разбейте проблему на более мелкие.

Если вы считаете, что вам нужно стать лучше, как разработчик, спросите себя: что на самом деле означает «лучше»?

Если вы новичок:
- Выберите область, на которой вы хотите сосредоточиться (веб-разработка, мобильные приложения, игры и т. д.).
- Выберите правильный язык программирования.
- Найдите дорожную карту и придерживайтесь её.

Если вы уже работаете над чем-то и чувствуете, что недостаточно хороши.
- Выясните, что именно доставляет вам проблемы: работа с БД, развёртывание, логика, архитектура?
- Поработайте над вашими конкретными недостатками.

Это гораздо меньше обескураживает — и определённо меньше угнетает. Ставит перед вами конкретные цели и конкретные пути их достижения.

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

Источник: https://dev.to/web_dev-usman/why-imposter-syndrome-is-part-of-every-developers-journey-2c0p
👍33
День 2271. #TipsAndTricks #Blazor
Пользовательская Страница 404 в Blazor
Иногда нужно иметь пользовательскую (дружелюбную к посетителю) страницу 404. Начиная с .NET 8 в Blazor Web App тег <NotFound> маршрутизатора (Router) больше не работает, поэтому создадим собственную страницу.

До Blazor Web App
Раньше можно было использовать следующий код внутри компонента маршрутизатора:
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData"
DefaultLayout="@typeof(MainLayout)"/>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Здесь идёт код, отображающийся когда страница не найдена.</p>
</LayoutView>
</NotFound>
</Router>

Это всё ещё будет работать со «старым» подходом, где вы выбираете либо Blazor WebAssembly, либо Blazor Server (с использованием файла <script src="_framework/blazor.server.js"></script>). Но в новых шаблонах веб-приложения Blazor это больше не работает. Вы всё ещё можете добавить дочерний элемент NotFound в разметку маршрутизатора, но он не будет использоваться. Всё потому, что сам маршрутизатор теперь другой. Новый шаблон проекта использует файл <script src="_framework/blazor.web.js"></script> в App.razor и имеет AddInteractiveServerComponents в контейнере сервисов.

Добавление страницы «не найдено»
Мы можем определить веб-страницу, которая будет отображаться с более низкой специфичностью, т.е. иметь наименьший приоритет. Назовём её NotFoundPage.razor:
@page "/{*route:nonfile}"

<p>Здесь идёт код, отображающийся когда страница не найдена.</p>

@code {
[Parameter]
public string? Route { get; set; }
}

Параметр Route не используется, но он обязателен. В противном случае Blazor выбросит исключение, что он не может связать маршрут со свойством.

Источник: https://steven-giesel.com/blogPost/38a4f1dc-420f-4489-9179-77371a79b9a9/a-custom-404-page-in-blazor-web-apps
👍3
День 2272. #УрокиРазработки
Уроки 50 Лет Разработки ПО

Урок 49. Разработчики программного обеспечения любят инструменты, но дурак с инструментами — это вооруженный дурак


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

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

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

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

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

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

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

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

Источник: Карл Вигерс “Жемчужины Разработки”. СПб.: Питер, 2024. Глава 6.
👍10👎2
День 2273. #ЗаметкиНаПолях
Подробный Обзор CallerMemberName. Начало
Имена методов меняются. И если вы используете имена методов в нескольких местах, указывая их вручную в виде строк, вы потратите много времени на их обновление. К счастью, в C# мы можем использовать атрибут CallerMemberName.

Его можно применить к параметру в сигнатуре метода, чтобы его значением во время выполнения было имя вызывающего метода:
public void SayMyName(
[CallerMemberName] string? methodName = null) =>
Console.WriteLine($"Моё имя {methodName ?? "NULL"}!");

Важно отметить, что параметр должен быть обнуляемой строкой. Если вызывающий объект устанавливает значение параметра, используется оно. В противном случае используется имя вызывающего метода (если оно есть).

1. Прямой вызов
public void DirectCall()
{
Console.WriteLine("Прямой вызов:");
SayMyName();
}

Вывод:
Прямой вызов:
Моё имя DirectCall!

Мы не указываем значение параметра methodName в SayMyName, поэтому по умолчанию используется имя вызывающего метода: DirectCall.

2. Явная передача параметра
public void DirectCallWithName()
{
Console.WriteLine("Прямой вызов с параметром:");
SayMyName("Уолтер Уайт");
}

Вывод:
Прямой вызов с параметром:
Моё имя Уолтер Уайт!

Важно отметить, что компилятор устанавливает значение methodName, только если оно не обозначено явно. При этом явная передача null
SayMyName(null);

приведёт к тому, что значение methodName будет null.

3. Вызов через Action
public void CallViaAction()
{
Console.WriteLine("Вызов через Action:");
Action<int> action = (_) => SayMyName();

var calls = new List<int> { 1 };
calls.ForEach(s => action(s));
}

Вывод:
Вызов через Action:
Моё имя CallViaAction!

Здесь интереснее: атрибут CallerMemberName распознаёт имя метода, содержащего общее выражение. Здесь синтаксически вызывающим является метод ForEach, но он игнорируется, поскольку фактический вызов происходит в методе CallViaAction. Во время компиляции, поскольку методу SayMyName не передаётся значение, оно автоматически заполняется именем родительского метода. Затем метод ForEach вызывает SayMyName, но methodName уже определён во время компиляции.

4. Лямбда-выражения
То же происходит и при использовании лямбда-выражений:
public void CallViaLambda()
{
Console.WriteLine("Вызов через лямбду:");

void lambdaCall() => SayMyName();
lambdaCall();
}

Вывод:
Вызов через лямбду:
Моё имя CallViaLambda!


Окончание следует…

Источник:
https://www.code4it.dev/csharptips/callermembername-attribute/
👍16
День 2274. #ЗаметкиНаПолях
Подробный Обзор CallerMemberName. Окончание
Начало

5. Тип dynamic
Что, если мы вызовем метод SayMyName на экземпляре типа dynamic? Допустим класс CallerMemberNameTests содержит метод SayMyName (определённый в предыдущем посте):
public void CallViaDynamic()
{
Console.WriteLine("Вызов через dynamic:");

dynamic obj = new CallerMemberNameTests();
obj.SayMyName();
}

В этом случае атрибут не работает, и мы получим NULL:
Вызов через dynamic:
Моё имя NULL!

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

6. Обработчики событий
Мы определяем события, но их обработчики выполняются не напрямую:
public void CallViaEvent()
{
Console.WriteLine("Вызов через события:");
var source = new MyEventClass();
source.MyEvent += (sender, e) => SayMyName();
source.TriggerEvent();
}

public class MyEventClass
{
public event EventHandler MyEvent;
public void TriggerEvent() =>
MyEvent?.Invoke(this, EventArgs.Empty);
}

Вывод:
Вызов через события:
Моё имя CallViaEventHandler!

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

7. Конструктор класса
Наконец, что происходит при вызове из конструктора?
public CallerMemberNameTests()
{
Console.WriteLine("Вызов из конструктора:");
SayMyName();
}

Мы можем рассматривать конструкторы как особый вид методов, но какое у него имя?
Вызов из конструктора 
Моё имя .ctor!

Фактическое имя метода — .ctor! Независимо от имени класса, конструктор считается методом с этим внутренним именем.

Источник: https://www.code4it.dev/csharptips/callermembername-attribute/
👍9
День 2275. #SystemDesign101
Как Улучшить Производительность API?


1. Пагинация
Это обычная оптимизация, когда размер результата большой. Результаты передаются клиенту частями для улучшения отзывчивости сервиса.

2. Асинхронное ведение журнала
Синхронное логирование пишет на диск при каждом вызове и может замедлить работу системы. Асинхронное логирование сначала отправляет логи в буфер без блокировки и немедленно возвращает управление. Логи будут периодически сбрасываться на диск. Это значительно снижает накладные расходы на ввод-вывод.

3. Кэширование
Мы можем хранить часто используемые данные в кэше. Клиент сначала запрашивает кэш. И только если в кэше нужных данных нет, они запрашиваются из БД. Кэши, такие как Redis или гибридный кэш, могут хранить данные в памяти, поэтому доступ к ним происходит намного быстрее, чем к БД.

4. Сжатие данных
Запросы и ответы можно сжимать с помощью gzip и подобных ему алгоритмов, чтобы размер передаваемых данных был намного меньше. Это ускоряет загрузку и скачивание.

5. Пул соединений
При доступе к ресурсам нам требуется загружать данные из БД. Открытие и закрытие соединений с БД добавляет значительные накладные расходы. Поэтому мы должны подключаться к базе через открытых соединений. Пул отвечает за управление жизненным циклом соединения.

Источник: https://github.com/ByteByteGoHq/system-design-101
👍22
День 2276. #ЗаметкиНаПолях
Разбираем Курсорную Пагинацию. Начало
Пагинация (разбиение на страницы) имеет решающее значение для эффективной доставки больших наборов данных. Хотя офсетная пагинация (offset pagination) широко используется, курсорная пагинация (cursor pagination) предлагает некоторые интересные преимущества для определённых сценариев. Она особенно ценна для каналов в реальном времени, интерфейсов бесконечной прокрутки и API, где производительность имеет значение в масштабе, например, для журналов активности или потоков событий, где пользователи часто просматривают большие наборы данных. Рассмотрим детали реализации и обсудим, где каждый подход имеет наибольший смысл.

Создадим простое хранилище записей пользователя с помощью следующего объекта:
public record UserNote (
Guid Id,
Guid UserId,
string? Note,
DateOnly Date
);


Традиционный подход: Офсетная пагинация
Офсетная пагинация использует Skip и Take. Мы пропускаем определённое количество строк и берём фиксированное количество строк. Обычно они преобразуются в OFFSET и LIMIT в запросах SQL:
var query = dbContext.UserNotes
.OrderByDescending(x => x.Date)
.ThenByDescending(x => x.Id);

// При офсетной пагинации мы обычно сначала считаем общее количество элементов
var total = await query
.CountAsync(cancellationToken);
var pages = (int)Math.Ceiling(total / (double)pageSize);

var query = dbContext.UserNotes
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);

Заметьте, что мы сортируем результаты по дате и Id в обратном порядке. Это обеспечивает единообразие результатов при разбиении на страницы. Вот сгенерированный SQL (в PostgreSql) для офсетной пагинации:
// запрос общего количества
SELECT count(*)::int FROM user_notes AS u;

// запрос данных
SELECT u.id, u.date, u.note, u.user_id
FROM user_notes AS u
ORDER BY u.date DESC, u.id DESC
LIMIT @pageSize OFFSET @offset;


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

Продолжение следует…

Источник:
https://www.milanjovanovic.tech/blog/understanding-cursor-pagination-and-why-its-so-fast-deep-dive
👍15
День 2277. #ЗаметкиНаПолях
Разбираем Курсорную Пагинацию. Продолжение

Начало

Курсорная пагинация
Курсорная пагинация использует контрольную точку (курсор) для извлечения следующего набора результатов. Эта контрольная точка обычно является уникальным идентификатором или комбинацией полей, которые определяют порядок сортировки.

Используем поля Date и Id для создания курсора для нашей таблицы UserNotes. Курсор представляет собой композицию этих двух полей, что позволяет нам эффективно выполнять пагинацию:
var query = dbContext.UserNotes.AsQueryable();

if (date != null && lastId != null)
{
// Используем курсор для извлечения части результатов
// При прямой сортировке нужно заменить > на <
query = query
.Where(x => x.Date < date ||
(x.Date == date && x.Id <= lastId));
}

// Извлекаем элементы на 1 больше
var items = await query
.OrderByDescending(x => x.Date)
.ThenByDescending(x => x.Id)
.Take(limit + 1)
.ToListAsync(cancellationToken);

// Определяем данные для следующей страницы
var hasMore = items.Count > limit;
DateOnly? nextDate =
hasMore ? items[^1].Date : null;
Guid? nextId =
hasMore ? items[^1].Id : null;

// Удаляем «лишний» результат, если он есть
if (hasMore)
items.RemoveAt(items.Count - 1);

Порядок сортировки такой же, как в примере офсетной пагинации. Однако порядок сортировки имеет решающее значение для согласованных результатов при курсорной пагинации. Поскольку Date не является уникальным значением в нашей таблице, мы используем поле Id для обработки границ страниц. Это гарантирует, что мы не пропустим и не продублируем элементы при пагинации. Вот сгенерированный SQL (PostgreSql) для курсорной пагинации:
SELECT u.id, u.date, u.note, u.user_id
FROM user_notes AS u
WHERE u.date < @date OR (u.date = @date AND u.id <= @lastId)
ORDER BY u.date DESC, u.id DESC
LIMIT @limit;

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

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

Ограничения курсорной пагинации:
- Если пользователям необходимо динамически изменять поля сортировки, курсорная пагинация становится значительно сложнее, поскольку курсор должен включать все условия сортировки;
- Пользователи не могут перейти к определённому номеру страницы — они должны последовательно проходить по страницам;
- Сложнее реализовать правильно по сравнению с офсетной пагинацией, особенно при обработке границ страниц и обеспечении различной сортировки.

Окончание следует…

Источник:
https://www.milanjovanovic.tech/blog/understanding-cursor-pagination-and-why-its-so-fast-deep-dive
👍15
День 2278. #ЗаметкиНаПолях
Разбираем Курсорную Пагинацию. Окончание

Начало
Продолжение

Улучшаем курсорную пагинацию
Для ускорения пагинации можно добавить индекс:
CREATE INDEX idx_user_notes_date_id ON user_notes (date DESC, id DESC);

Индекс создаётся в обратном порядке, чтобы соответствовать порядку в запросах. Однако, если выполнить план запроса, мы увидим, что индекс используется, но запрос может выполняться даже дольше, чем без него:
EXPLAIN ANALYZE SELECT u.id, u.date, u.note, u.user_id
FROM user_notes AS u
WHERE u.date < @date OR (u.date = @date AND u.id <= @lastId)
ORDER BY u.date DESC, u.id DESC
LIMIT 1000;

Возможно, размер данных слишком мал, чтобы получить преимущество от индекса. Но мы можем использовать один трюк – сравнение кортежей:
SELECT u.id, u.date, u.note, u.user_id
FROM user_notes AS u
WHERE (u.date, u.id) <= (@date, @lastId)
ORDER BY u.date DESC, u.id DESC
LIMIT 1000;

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

У провайдера EF для Postgres есть метод EF.Functions.LessThanOrEqual, который принимает ValueTuple в качестве аргумента. Мы можем использовать его для создания сравнения кортежей:
query = query.Where(x => EF.Functions.LessThanOrEqual(
ValueTuple.Create(x.Date, x.Id),
ValueTuple.Create(date, lastId)));


Кодирование курсора
Мы можем закодировать курсор, используемый для извлечения следующего набора результатов. Клиенты получат курсор в виде строки Base64. Им не нужно знать внутреннюю структуру курсора:
using System.Buffers.Text;
using System.Text;
using System.Text.Json;

public record Cursor(DateOnly Date, Guid LastId)
{
public string Encode() =>
Base64Url.EncodeToString(
Encoding.UTF8.GetBytes(
JsonSerializer.Serialize(this)));

public static Cursor? Decode(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
return null;

try
{
return JsonSerializer.Deserialize<Cursor>(
Encoding.UTF8.GetString(
Base64Url.DecodeFromChars(cursor)));
}
catch
{
return null;
}
}
}

Вот пример использования:
var cursor = new Cursor(new DateOnly(2025, 4, 26),
Guid.Parse("019500f9-8b41-74cf-ab12-25a48d4d4ab4"));

var encoded = cursor.Encode();
// eyJEYXRlIjoiMjAyNS0wMi0xNSIsIkxhc3RJZCI6IjAxOTUwMGY5LThiNDEtNzRjZi1hYjEyLTI1YTQ4ZDRkNGFiNCJ9

var decoded = Cursor.Decode(encoded);


Источник: https://www.milanjovanovic.tech/blog/understanding-cursor-pagination-and-why-its-so-fast-deep-dive
👍20
День 2279. #УрокиРазработки
Уроки 50 Лет Разработки ПО

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


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

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

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

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

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

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

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

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

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

Источник: Карл Вигерс “Жемчужины Разработки”. СПб.: Питер, 2024. Глава 6.
👍8