Русский
Русский
English
Статистика
Реклама

Net core

Я десять лет страдал от ужасных архитектур в C приложениях и вот нашел, как их исправить

01.09.2020 18:20:28 | Автор: admin


Я второй десяток лет участвую в разработке приложений для бизнеса на .NET и каждый раз вижу одни и те же проблемы быдлокод и беспорядок. Месиво из сервисов, UoW, DTO-шек, классов-хелперов. В иных местах и прямой доступ в базу данных руками, логика в статических классах, километровые портянки конфигурации IoC.


Когда я был молодым и резвым мидлом я тоже так писал. Потом бил кулаком в стену с криками: "Хватит! В следующий раз сделаю по-другому". Следующий раз действительно начинался "по-другому" с холодной головой и строгим подходом к архитектуре а на выходе все равно получалась та же субстанция, лучше на пару миллиметров.


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


Откуда берётся бардак


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


Если отбросить человеческие аспекты и оставить технически-архитектурные, то откуда берется весь бардак?


Это IoC!


Нет, вы только поймите правильно: инверсия зависимостей и лайфтайм-менеджмент из одной точки приложения это прекрасно. Но что лежит в наших контейнерах? Connection к базе данных (один на подключение), пачка каких-нибудь параметров и credentials, надёрганных из web.config (обычно синглтон) и огромные, бескрайние километры разнообразных сервисов-UoW-репозиториев. В худшем случае в формате интерфейс-реализации.


Мы это делаем, чтобы наши приложения в любой момент можно было разобрать на составные кусочки например, для тестирования или отдачи подсистемы на разработку другой команде. Цель благородная! Ради неё, наверное, можно хранить портянки IoC-конфигурации и по два файла для каждого компонента (интерфейс-реализация).


Или нет? И тут я хочу помолиться за команды, которые догадываются использовать механизм модулей, предоставляемый почти любым IoC-фреймворком: парни, вы делаете ну хоть что-то, крепитесь.


Это потому что нет unit-тестов!


Я вам расскажу почему их нет.


Вот давайте по чесноку: все же пробовали писать unit-тесты для традиционных C#-проектов, сделанных по методологии "UoW и Repository"? На всё вышеупомянутое содержимое контейнера надо написать заглушки и вбить тестовые данные (руками). Ну или пойти попросить себе виртуалку под тесты, на которую надо вкатить БД и залить данные, заботливо украденные с продакшена. И подчищать их после каждого прогона тестов.


В итоге на один честный тест одного метода бизнес-логики у вас уходит хорошо если день времени. А потом он начинает периодически падать потому что инфраструктура поменялась (базу переместили в другое место), коннекшн отвалился по таймауту, данные пришли позже и т.д. Стандартным ответом на это является всеми нами любимое "Билд упал? Да ерунда, перезапусти".


Такими темпами вы постепенно забиваете на написание тестов, ограничиваясь проверками каких-нибудь тривиальных хреновин вроде того, что метод копирования полей в класс из 10 строчек действительно, блин, копирует поля! Такие тесты остаются в системе навечно и всегда выполняются "для галочки", падая, может, раз в год, когда неопытный джун сдуру сотрёт строчку при мердже. Всё остальное время они представляют собой театр безопасности кода. Все прекрасно понимают что реальное тестирование происходит "вручную", QA-отделом (в лучшем случае автоматизировано, end-to-end), а пользователи вроде не жалуются.


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


Это потому что мы лезем в базу руками!


Да неужели? А я-то думал что вы, как настоящие мужики, терпите, пока O/RM удаляет 3000 объектов. Все, кто хочет чтобы их приложение более-менее сносно работало по скорости рано или поздно лезут в базу руками. Ну потому что она база. Потому что объектная модель натягивается на реляционную как сова на глобус долго, медленно и со слезами (см. object-relational impedance mismatch). А тут ещё и O/RM поощряет такие кренделя, оставляя доступ напрямую к базе (ибо как если этого не делать его пользователи с потрохами сожрут). Как же тут не соблазниться возможностью решить задачу быстро и просто, когда нужно медленно и сложно. Безусловно, любители посылать базе SQL из приложения сами виноваты. Однако делают это не от хорошей жизни.


Это не только базы касается имейлы тоже часто отправляют прямо из логики. И запросы ко внешним вёб-сервисам, и сообщения в очередь пихают. Очень смешно, кстати, получается, если транзакция в БД по каким-то причинам упала, а e-mail ушёл. Транзакцию-то можно откатить, а вот письмо уже обратно не всосёшь.


Это потому что мы не следуем паттернам!


Да кто ж вам мешает пожалуйста, следуйте. Но давайте, попробуем реализовать простую фичу. Что там надо? Написать репозиторий пользователей. Интерфейс + реализация, сделать то же самое для заказов, на основе них создать Unit of Work. Разумеется, тоже побив на интерфейс и реализацию. Потом сделать две DTOшки (а возможно больше), потом сервис, в котором использовать описанный Unit of Work, тоже разбив на две части.


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


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


И их продукты иногда стреляют, после чего переписывать прототип уже нет времени надо фичи деливерить! И если повезёт, то продукт разрастается, получает прибыль и инвестиции. А после нескольких таких фазовых переходов превращается в ту самую огромную IT-компанию, где золотые слитки и джуниоры.


Это потому что у нас нет архитектора!


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


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


Выясняется, что красивая и грамотная архитектура (в 99% случаев) требует кучи телодвижений для решения простейших задач и внесения простейших правок. В итоге на красивый дизайн кладётся моржовый хрен и пишется так, чтобы быстрее решить задачу, учесть правки, пофиксить баг. Вершина пост-иронии если на самого архитектора падает 50 тикетов. Ах это незабываемое зрелище, ах эти незабываемые звуки! Принципиальный блюститель паттернов и правил сам начинает писать портянки попахивающего кода в обход всякой архитектуры. Пыхтит, корчится, шаблон трещит на весь офис лишь бы начальство по жопе не дало за сорванные сроки. Словами не передать: блажен, кто видел мир в его минуты роковые.


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


Это потому что фрейморк XXX фигня!


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


Обычно тезис из подзаголовка идёт в комплекте с "давайте всё перепишем на YYY"! Именно такие сентенции выдают розовощёкие детишки, протерев лицо от печенек с конференционного кофе-брейка. Зуб даю, именно там они про волшебный YYY и услышали.


И если бы эти ребята поглощали печенья чуть меньше, а своей головой думали чуть больше они бы заметили, что YYY, который сделала для себя компания GGG ориентирован на решение проблем GGG. В выпуске подкаста "Мы обречены" со мной, Фил хорошо сформулировал на примере redux: "redux это фреймворк для управления состоянием Но не твоим состоянием!". И пусть вдохновляющие примеры а-ля "делаем на YYY приложение для подсчёта коров за 5 минут" не вводят вас в заблуждение. Между подсчётом коров и вашей условной системой автоматизации похоронного бизнеса всё же есть некоторая разница.


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


Очень часто технология YYY предсказуемо рвётся от несовместимости себя любимой с объективными реалиями вашей системы. А ещё бывает так, что YYY ещё незрелый, страдает от детских болезней и будет готов к production-использованию спустя пару лет. Если его использовать прямо сейчас, то проект или благополучно подохнет, или всё вернётся на круги своя, а розовощёкие мальчики поедут кушать ещё больше печенья и искать очередную серебряную пулю.


Как прибрать весь этот мусор


Впрочем, довольно хаять объективную реальность. Ныть всякий может. Лично я хочу иметь инструмент, который я могу взять при старте проекта, просто использовать его, и оно просто будет работать. Давайте формализуем моё "просто работать" в конкретные пункты, что хочется видеть в результате:


Жизнеспособность в долгосрочной перспективе


Я уже несколько раз в одно рыло апдейтил 2000 файлов в проекте (один раз даже с помощью VB.NET-C# транслятора), больше не хочу. Хочу видеть архитектурно такую систему, которая без разрывов в паху будет резаться на части и горизонтально расширяться логически.


Минимум кода, максимум логики


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


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


Чёткая организация


Задолбало думать куда положить очередной сервис, в какую сборку поместить интерфейс, в какой неймспейс кинуть DTO-шку. Так и ладно я мне эти размышления просто замедляют работу. А придёт на проект сотня джуниоров они ведь начнут кидать всё, куда ни попадя потому что Вася у нас художник, Вася так видит. А умудрённых сединами дедов на них на всех не хватит, чтобы в каждом отдельном случае давать ценные указания. Хочу чтобы архитектурный каркас предусматривал чёткие, понятные даже обезьяне правила и форсировал их соблюдение. Можно выпендриться и сказать "формируя у разработчика императив поведения", но я скромный.


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


Строгая типизация


Больше строгой типизации! Нам дали в руки компилятор так давайте выжмем из него по-максимуму! Пусть трудится на благо бизнеса. Собрать ваши интерфейсы, написанные по образу и подобию Java-вских, компилятор всегда сможет. Другое дело что пишем-то мы на C# и языковых возможностей у нас куда как больше дженерики вон хорошие, лямбды, разные модификаторы доступа, экстеншны, ковариантность!


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


Удобная абстракция от внешних систем


Не хочу писать логику, полагаясь на то, что у нас есть конкретная база данных, конкретная очередь сообщений, конкретный почтовый клиент. А вот чтобы поменять базу (даже сам движок), очередь и почтовый клиент при необходимости можно было без изменений логики. Обычно для таких кренделей нужно самостоятельно выделять нужные интерфейсы и фаршировать ими DI-контейнер. Здесь же мне хочется иметь общие интерфейсы работы заранее, из коробки. В идеале чтобы не делать эту работу заново из проекта в проект.


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


Я к тому, что к багам в логике у меня один подход, к багам с внешними системами другой. Хочется чтобы дизайн системы как-то ну я не знаю Эксплицитно обыгрывал этот момент.


Кстати ещё хочу чтобы архитектура хорошо подстраивалась под работу с несколькими базами данных одновременно. Решить, так сказать, проблему абстрактно.


Решение проблем с тестированием


Тесты штука нужная и важная. Не компилятором единым. Мой опыт подсказал, что регрессионного unit-тестирования на кейсах более чем достаточно. Если делать и поддерживать такие тесты будет дёшево, то их можно будет сделать много. А тут уже можно взять количеством. Как говорится, главное завалить а там ногами запинаем.


Категорически необходимо как-то решить проблему с хранением тестовых данных. Тестовая база для разработки у меня есть, конечно же. Но поднимать её в рамках CI-билда категорически не хочется. Да и потом я не хочу тестировать сервер баз данных! Его уже тестируют умные дяденьки за большие деньги. Надо чтобы тестировался мой код, который я написал. А всё остальное не надо.


Вбивать руками тестовые данные, которые уже, блин, лежат в базе тоже не хочу. Разве умная машина не может сделать это сама?


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


Тут ещё вот что надо заметить: как мы разрабатываем? Заводим тестовые данные, что-то пишем, запускаем-дебажим, уточняем требования, снова что-то пишем, снова дебажим Потом в какой-то момент коммитим задачу, пропускаем через QA, мерджим бренч, после чего задача считается решённой. Это логично, все так делают. Так вот, хочется впаять автоматизированное тестирование в этот процесс так, чтобы после приёмки, когда все от QA до бизнеса сказали "да, это работает правильно", настрогать тестов, не меняя функциональность и кинуть в общую кучу, дабы прогонялись каждый билд. Можно даже какой-нибудь code coverage замутить. Меняешь логику видишь что упало. Ну круто же!


А вот TDD на фиг если честно. Невозможно загодя написать тесты для фичи, функциональность которой толком не известна до момента её сдачи. Поэтому ориентироваться на TDD я не стану.


И таким образом...


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


За сим откланяюсь, до следующей статьи.


Кому не терпится посмотреть репозиторий тут

Подробнее..

Поддержка процессоров Apple M1 в .NET

21.11.2020 22:12:40 | Автор: admin

17 ноябряAppleофициально представила устройства на базе своего нового ARM-процессораAppleM1. Естественно, это событие не могло быть не замечено со стороны компанииMicrosoft, которая с 2014 года начала активную экспансию .NET на новые платформы. Давайте посмотрим, что нас ждет в связи с этим в ближайшее время.

Начнем пожалуй с инструментов, а затем перейдем к самой платформе .NET, но если вам не терпится узнать, получат ли новые маки поддержку .NET, то вот вам небольшой спойлер:

Spoiler

Да, на новых маках будет .NET

Visual Studio Code

Команда разработчиков Visual Studio Code уже объявила о том, что работает над поддержкой новых процессоров. На странице загрузок Insider Preview для macOS уже появились опция для загрузки экспериментальной сборки с поддержкой ARM. Следить за работой команды можно на официальном аккаунте в GitHub.

Visual Studio for Mac

Если команда VS Code уже подготовила тестовые сборки с поддержкой Apple M1, то их коллеги из команды Visual Studio for Mac оказались не так расторопны:

Впрочем Visual Studio for Mac гораздо более крупный и сложный проект, поэтому портирование его на новый процессор может занять несколько больше времени. Сейчас эта версия IDE может работать при поддержке Rosetta 2.

На данный момент у владельцев новых ноутбуков Apple наблюдаются некоторые проблемы при отладке проектов на Xamarin.Forms для iOS. Соответствующий баг уже заведен в репозитории проекта Xamarin.iOS & Xamarin.Mac.

Rider

В JetBrains уже объявили, что они работают над переносом JetBrains Runtime (и всех продуктов, работающих на JVM, в том числе и Rider) на Apple Silicon. На данный момент IDE от JetBrains работают на чипах Apple Silicon через Rosetta 2. Правда не все функции работают в этом режиме стабильно. Так, например, многие жалуются на то, что отладка в Rider сейчас не работает.

Docker

Docker стал практически must have инструментом для современного разработчика. У Майкрософт есть обширный набор образов для .NET, но к сожалению, воспользоваться вы ими на ноутбуке с новым процессором от Apple пока не сможете.

Будем надеяться, что в ближайшее время поддержка M1 будет реализована в Docker.

.NET

А теперь перейдем к самому главному получит ли новый чип поддержку .NET?

Те, кто подсмотрели спойлер в начале статьи уже знают ответ на этот вопрос. Команда разработки .NET активно работает над поддержкой Apple M1. Для этого даже был создан отдельный проект в трекинге платформы. Стоит отметить тот факт, что текущая версия платформы (а именно, недавно ушедший в релиз .NET 5) будет работать поверх Rosetta. А вот в .NET 6 уже будет нативная поддержка нового чипа. Согласно планам Microsoft, произойдет это не раньше, чем через год:

Из того, что уже выполнено, я бы отдельно отметил такие задачи:

Также запланирована поддержка нового процессора в ASP.NET Core.

Но несмотря на то, что официальной поддержки новых процессоров придется ждать почти год, уже доступна к загрузке альфа-версия .NET 6.0. На момент написания статьи, это версия 6.0.0-alpha.1.0562.6.

Mono

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

Проекты, которые вскоре должны получить поддержку Apple M1Проекты, которые вскоре должны получить поддержку Apple M1

Самое большое изменение, которое было сделано для поддержки процессора M1 связанно с тем, как работает JIT, а именно, с изменение состояния потоков. Это было реализовано с помощью новых макросов в mono/mini.h. Они были встроены в систему из соображений производительности.

Rosetta 2

В этой публикации не один раз упоминалась технология Rosetta 2. Для тех, кто не знает, что это, приведем пояснение, которое размещено на странице портала Apple Developer:

Rosetta - это процесс трансляции, который позволяет пользователям запускать приложения, содержащие инструкции x86_64, на микросхеме Apple. Rosetta призвана упростить переход на микросхему Apple, давая разработчикам время на создание универсального двоичного кода приложений. Если исполняемый файл содержит только инструкции Intel, macOS автоматически запускает Rosetta и начинает процесс трансляции. По окончании трансляции система запускает подготовленный исполняемый файл вместо оригинала. Однако процесс перевода требует времени, поэтому транслированные приложения иногда запускаются или работают медленнее.

Итоги

Новый процессор (а соотвественно устройства, которые будут на основаны на нем) без сомнений получит нативную поддержку в .NET, впрочем эта задача не является приоритетной в текущем роадмапе, поэтому ждать ее придется не раньше, чем в релиз уйдет шестая версия платформы. До того момента можно будет работать c .NET, используя возможности Rosetta 2. Что касается инструментария для разработчиков, то я могу предположить, что в ближайшие пол года основные проблемы будут решены (возможно даже с участием Apple) и уже к апрелю можно будет потихоньку присматриваться к компьютерам на базе Apple M1 в качестве рабочего инструмента.

Подробнее..

Провайдер логирования для Telegram (.NET 5 .NET Core)

27.01.2021 18:04:51 | Автор: admin

Не секрет, что Telegram является на данный момент одним из самых популярных мессенджеров. Особенно в среде ИТ-специалистов. Он удобен, в нем нет встроенной рекламы и работает весьма стабильно. Довольно большую часть времени я общаюсь как по работе, так и по личным вопросам именно в этом мессенджере. Поэтому в один прекрасный день я подумал о том, что было бы удобно, чтобы в этом же мессенджере я мог получать уведомления о работе некоторых своих сервисов. На тот момент я как раз активно работал над интеграцией проекта //devdigest и Telegram, поэтому используя тот же родной Telegram Bot SDK довольно быстро реализовал логгер.

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

Вот в таком виде приходят логи в телеграмм из одного из проектов, которыми я занимаюсьВот в таком виде приходят логи в телеграмм из одного из проектов, которыми я занимаюсь

Подготовка

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

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

Дале нужно получить идентификатор канала. Для публичных каналов все просто - в качестве идентификатора мы можем использовать имя канала. Для приватных каналов все чуть-чуть сложнее.

Получение идентификатора приватного канала.

Чтобы получить идентификатор приватного канала придется воспользоваться помощью еще одного бота @JsonDumpBot. Вам нужно будет любое сообщение из этого канала переслать в этот бот. В ответ вы получите сообщение примерно такого вида:

{  "update_id": 111001100,  "message": {    "message_id": 123456,    "from": {      "id": 12345678,      "is_bot": false,      "first_name": "FirstName",      "username": "username",      "language_code": "en"    },    "chat": {      "id": 123456,      "first_name": "FirstName",      "username": "username",      "type": "private"    },    "date": 1111111111,    "forward_from_chat": {      "id": -1123456789101,      "title": "torf.tv logs",      "type": "channel"    },    "forward_from_message_id": 1,    "forward_date": 1111111111,    "text": "test"  }}

Идентификатор канала находится в блоке forward_from_chat -> id

Теперь, имея идентификатор чата и токен бота мы можем перейти к настройке логгера.

Настройка логгера

Для конфигурации логгера используется класс TelegramLoggerOptions, который содержит следующие поля:

  • AccessToken токен бота;

  • ChatId идентификатор канала (приватного, или публичного), или чата, куда бот будет отправлять сообщения;

  • LogLevel минимальный уровень сообщений, которые будут отправляться в канала. Обычно я в канал отправляю сообщения начиная с уровня Warning, или Error;

  • Source удобочитаемое название сервиса. Полезно, если в один канал приходят сообщения из нескольких сервисов;

Существует несколько вариантов конфигурации логгера непосредственно через код, или через файл кофигурации.

Настройка логгера в коде

Для начала нужно создать и инициализировать экземпляр класса TelegramLoggerOptions.

var options = new TelegramLoggerOptions{    AccessToken = "1234567890:AAAaaAAaa_AaAAaa-AAaAAAaAAaAaAaAAAA",    ChatId = "-0000000000000",    LogLevel = LogLevel.Information,    Source = "Human Readable Project Name"};

Зачем передать этот объект в метод-расширение AddTelegram():

builder  .ClearProviders()  .AddTelegram(options)  .AddConsole();

Пример такой конфигурации можно посмотреть здесь.

Настройка логгера через файл конфигурации appconfig.json

Также блоггер можно настраивать через файл конфигурации приложения, как это показано ниже:

{  "Logging": {    "LogLevel": {      "Default": "Information",      "Microsoft": "Warning",      "Microsoft.Hosting.Lifetime": "Information"    },    "Telegram": {      "LogLevel": "Warning",      "AccessToken": "1234567890:AAAaaAAaa_AaAAaa-AAaAAAaAAaAaAaAAAA",      "ChatId": "@channel_name",      "Source": "Human Readable Project Name"    }  },  "AllowedHosts": "*"}

Далее, в метод-расширение AddTelegram() необходимо передать экземпляр IConfiguration,

public static IHostBuilder CreateHostBuilder(string[] args) =>    Host.CreateDefaultBuilder(args)        .ConfigureLogging((context, builder) =>        {            if (context.Configuration != null)                builder                    .AddTelegram(context.Configuration)                    .AddConsole();        })        .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<startup>(); });

Пример находится здесь

Установка

Установить логгер можно из NuGet, или же интегрировать код прямо к себе в проект. Библиотека распространяется под лицензией MIT.

Подробнее..

Работа с большими решениями .NET 5 в Visual Studio 2019 16.8

10.02.2021 10:11:22 | Автор: admin

С выпуском .NET 5 миграция решений из .NET Framework увеличилась. В частности, мы начали наблюдать перемещение очень крупных решений. Чтобы обеспечить максимальное удобство, мы работали над оптимизацией Visual Studio для обработки решений, содержащих большое количество проектов .NET 5 и .NET Core. Многие из этих оптимизаций были включены в версию 16.8, и в этом посте рассматриваются внесенные нами улучшения.

Запуск компилятора C# и VB вне процесса

Roslyn, компилятор C# и Visual Basic, парсит и анализирует все решение для поддержки служб, таких как IntelliSense, Go to Definition и диагностика ошибок. В результате Roslyn имеет тенденцию потреблять ресурсы, которые увеличиваются пропорционально размеру открытого решения, что может стать весьма значительным для больших решений. Команда Roslyn уже работала над минимизацией этого воздействия, активно кэшируя информацию на диске, которая не требуется немедленно, но даже при таком кэшировании нельзя избежать необходимости хранить данные в памяти.

Чтобы уменьшить влияние на более крупные решения Visual Studio, команда Roslyn приложила значительные усилия, чтобы вывести компилятор Roslyn из процесса Visual Studio в его собственный процесс. Запуск Roslyn в собственном процессе освобождает ресурсы в самой Visual Studio и дает компилятору Roslyn больше места для выполнения своей работы. Для больших решений это может сэкономить до трети памяти, потребляемой Visual Studio при открытии большого решения.

Оптимизация узла зависимостей

Каждый проект .NET 5 и .NET Core имеет узел в обозревателе решений с именем Dependencies, который отображает все, от чего зависит проект: другие проекты, сборки, пакеты NuGet и т.д. В дополнение к отображению непосредственных зависимостей проекта, узел также показывает транзитивные зависимости проекта, т.е. все, от чего зависит каждая зависимость, и так далее. Для проекта любого разумного размера этот список транзитивных зависимостей может стать довольно большим.

К сожалению, первоначальная реализация узла Dependencies не была очень эффективной в том смысле, что она хранила информацию о переходных зависимостях в памяти. В нем содержалось гораздо больше информации, чем было необходимо, и большая часть данных была избыточной. Мы переписали код, чтобы сохранить только ту информацию, которая абсолютно необходима, и начали использовать существующую информацию о зависимостях, которая уже хранится в NuGet. Эта перезапись сэкономила до 1015% памяти, потребляемой Visual Studio при открытии большого решения.

Уменьшение дублирования информации в MSBuild

После Roslyn одним из других основных потребителей ресурсов в процессе Visual Studio является MSBuild. Это связано с тем, что в качестве механизма сборки большая часть среды IDE основана на объектной модели MSBuild. Хотя мы сделали сами файлы проектов намного меньше в .NET 5 и .NET Core, существует значительное количество вспомогательных файлов проектов, которые импортируются в проекты через SDK. Оценка всех этих файлов необходима для понимания и построения проекта и может потреблять до трети памяти, нужной Visual Studio при открытии большого решения.

Хотя файлы проекта генерируют много данных, большая часть этих данных повторяется, и мы начали дедупликацию этих данных в памяти. Строки - один из наиболее распространенных побочных продуктов системы проектов, и они хранят такую информацию, как имена файлов, параметры и пути. В частности, пути могут быть довольно длинными и потреблять много памяти, если их слишком много или они дублируются слишком много раз. Обеспечено хранение только одной копии строки, что позволяет сэкономить до 510% памяти, потребляемой Visual Studio при открытии большого решения.

Уменьшение количества копий проекта, удерживаемых системой проектов

Одним из важных аспектов проектирования системы проектов .NET 5 и .NET Core является асинхронность. Часто системе проекта требуется выполнять работу в ответ на действие пользователя (например, добавление новой ссылки в проект). Вместо того, чтобы блокировать Visual Studio, пока она завершает свою работу, система проектов позволяет пользователю продолжать работу в среде IDE и выполнять работу в фоновом режиме.

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

Улучшения загрузки большого решения

Проделав всю эту работу, мы значительно оптимизировали работу с большими решениями .NET 5 и .NET Core. Начиная с версии 16.8, во многих наших тестах мы наблюдали увеличение в 2,5 раза размера решения, которое мы можем открыть, прежде чем столкнуться с проблемами ресурсов. Мы также наблюдаем снижение до 25% сбоев, сообщаемых из-за исчерпания ресурсов.

Перечисленные выше улучшения - это только начало изменений, которые мы вносим для улучшения работы с большими решениями в Visual Studio. Производительность отдельных решений по-прежнему может варьироваться в зависимости от размера решения, типа проектов, загруженных расширений и т.д., И есть области, которые мы ищем для улучшения. Мы рекомендуем всем пользователям, у которых возникают проблемы с медленной загрузкой или сбоями при загрузке решений, обращаться к нам по адресу vssolutionload@microsoft.com, чтобы мы могли продолжать улучшать загрузку решений всех типов!

Подробнее..

Как я решил протестировать нагрузочную способность web сервера

20.04.2021 10:21:38 | Автор: admin

В одно прекрасное утро вдруг пришла "гениальная" мысль а не протестировать ли мне что ни будь? Посмотрев по сторонам, на глаза мне попался он - герой этой статьи.

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

И так дано - web сервер. Написан на .net core. Сервер используется в корпоративной разработке.

Посмотреть, как работает можно, например здесь бесплатный сервис хранение ссылок http://linkin.link. Про него я писал тут http://personeltest.ru/aways/habr.com/ru/users/developer7/posts

Вступление

Собственно, как тестировать web сервер? Если посмотреть на проблему в лоб то веб сервер должен отдавать все страницы, которые были запрошены клиентами. И желательно отдавать быстро.

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

Начинать надо с малого и для начала решил проверить самый простой и очевидный сценарий. Есть web сервер. Делаем на него запросы. Все запросы должны вернутся без ошибок и вовремя.

Задача поставлена. Тестировать решил так. На одной машине под windows 10 запускаю web сервер. На web сервере запущен сайт. На сайте размещено куча js, сss, mp4 файлов и, собственно, html страничка. Для простоты я просто взял страницу из готового сайта.

Чем досить сервер? Тут 2 пути скачать что-то готовое или написать свой велосипед. Я решил остановится на втором варианте. И этот выбор я сделал по нескольким причинам.

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

Httpdos

Сказано сделано.

Программа работает так - при старте читается файл urls.txt где занесены url которые надо скачивать. Далее нажимаем старт. Создаются список Task по количеству url. Каждый Task открывает socket, отправляет http запрос, получает данные, закрывает socket. Далее процедура повторяется.

Изначально использовал TcpClient но по каким-то причинам он выдавал почти сразу на старте множество ошибочных загрузок. Не стал разбираться, а спустился пониже. Реализовал отправку на socket.

В программу добавил таблицу url, количество удачных скачиваний, количество ошибок, размер закачки.

Ошибки считал по 2 сценариям. Все exception связанные с открытием, отсылкой, приёмом данных. Собирательно если хоть, где что-то не так убиваем socket, инкрементируем счётчик ошибок. Ещё добавил проверку длины данных. При первой закачке страницы запоминается длина данных, все повторные закачки сверяются с этим числом. Отклонение считается ошибкой. Можно было бы добавить и что-то другое но для начала мне и этого достаточно.

Всё для эксперимента готово. Запускаем web сервер. Запускаем httpdos так назвал программу, чтобы дальше можно было использовать это короткое название. И вижу следующую картину.

Тут я уточню некоторые технические данные. Количество потоков делающие запросы получилось 1200. Эта цифра количество url прочитанных из файла urls.txt, плюс я решил умножить все запросы в 20 раз. Все цифры взял из головы на момент написания программы. В любой момент можно поставить любые другие по желанию. Преимущество велосипеда.

Так же в последствии я добавил сбоку textarea куда вывожу все exception, и ещё решил добавить вывод - количество запросов в секунду.

Картинка меня порадовала. Во первых - всё работает ). Во вторых работает без ошибок. Количество обработанных запросов получилось где-то 4000-6000 в секунду.

Откуда такая цифра? По моим размышлениям она зависит от многих обстоятельств. Самая очевидное это какого размера сами запросы. Как я писал выше я просто скачиваю все данные с определённой web страницы, которая была взята из стороннего web проекта. И там много mp4 файлов, размер которых под 3 мегабайта. Если уменьшить размер запросов, например скачивать только css наверняка количество обработанных запросов увеличится. Мне даже стало интересно, и я начал играть с исходным кодом как со стороны web сервера, так и со стороны httpdos. Там есть куча различных таймеров, буферов и прочего. Я смотрел, как то или иное изменение, окажет влияние на скорость.

Но не буду писать об этом. Да и цель была проверить отказоустойчивость, а не скорость. Простая проверка для начала - зависнет ли сервер от dos атаки или просто будет медленнее обрабатывать запросы.

Так же существенное влияние на скорость оказывало, то что web сервер и httpdos был запущен в режиме отладки в Visual Studio.

Поработав пару минут ни одной ошибки. Посмотрел загрузку процессора диспетчер задач показал примерно 28% на web сервер и 20% на httpdos. Процессор стоит i7-8700k. Не разогнанный. Это 6 ядерный 12 поточный камень. В процессе работы куллер охлаждения не было слышно проц холодный. Специально температуру не смотрел.

Решил параллельно с httpdos сделать загрузки js файла через браузер. Файл закачивается мгновенно. Т.е. httpdos не оказывает существенного влияния на web сервер.

Продолжаю эксперименты. Решил запустить три программы httpdos одновременно. И понаблюдать за результатами. То, что я увидел, и я является причиной появления этой статьи.

А у видел я, что по каким-то причинам спустя примерно минуту начали появляться ошибки загрузок.

И я начал расследование.

Такого поведения просто не должно быть!

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

То, что произошло дальше заставило меня напрячься.

В процессе экспериментов я и так и сяк изгалялся над httpdos. И один сценарий привёл к неожиданным результатам.

Если запустить не менее 3 программ. Думаю, что такое количество связанно с количеством одновременных запросов в секунду. Начать dos атаку. Дождаться появления ошибок. Потом резко закрыть программы. То слушающий socket web сервера просто умирает! Вот так нету никакой ошибки на стороне сервера. Все потоки работают. А socket ничего не принимает. Это уже ни в какие ворота.

Эксперименты показали, что socket оживал примерно через 4 минуты, но, если dos атаку проводить долго socket умирал навсегда, по крайней мере я минут 15 ждал оживления, а дальше уже и не интересно было. Такого поведения просто не должно быть!

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

Если перезапускать web сервер, т.е. получается мы пересоздаём слушающий socket, то socket начинал принимать клиентов сразу.

Первый шаг

Первое что я сделал - решил попробовать получить более подробную информацию из exсeption на стороне httpdos. Полазив по интернету, нашёл что мне нужен SocketException, а в нём посмотреть свойство ErrorCode. Сделано. Получил код ошибки 10061 - WSAECONNREFUSED. Тут пояснение.

В соединении отказано.

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

Ну информативно(сарказм) однако. Вроде как это стоит понимать, что socket, к которому мы хотим подключится как бы и нету.

Запускаем консоль. Вводим netstat -an и видим. Вот он родненький. Слушает 80 порт. Ну по крайней мере система так думает.

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

Пару слов о socket.

В веб сервере используется самый стандартный способ открытия и работы с socket в .net. Вот пример кода:

Socket listenSocket = new Socket(AddressFamily.InterNetwork,                                 SocketType.Stream, ProtocolType.Tcp){NoDelay = true,Blocking = false,ReceiveBufferSize = TLSpipe.TLSPlaintext_max_recive,SendBufferSize = TLSpipe.TLS_CHUNK};//~~~listenSocket.Bind(new IPEndPoint(point.ip, port.port));Socket socket = await listenSocket.AcceptAsync();

Как видно для работы с слушающим socket нужно 3 действия. Создали. Слушаем. Принимаем клиентов. Схема настолько простая что программисту не остаётся никаких способов для манёвров, ну или возможностей отстрелить себе ногу.

Предполагается что используй вот так (куда ещё проще?) и не иначе и всё у тебя должно работать. Но как показывает практика есть нюансы.

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

Soсket socket = await listenSocket.AcceptAsync();

Затем подвесил socket. Попытались присоединится клиентом. Программа висела в вышеприведённой строчки кода. Тут мы увидели, что проблема не в коде после socket, а где то внутри socket.

Дальше я решил поэкспериментировать с настройками открытия socket. Собственно, в своё время все настройки и так были исследованы, и оставлены только те, что нужно. Но в свете последних событий никто не уйдёт от (подозрений) экспериментов.

Под подозрения попали следующие настройки.

  1. ReceiveTimeout

  2. SendTimeout

  3. Ttl

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

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

Что касается тайм аутов. В коде сервера реализованы различные таймауты, но на более высоком уровне. В приёмнике есть таймауты на приём шапки http, определения длины body, расчёт времени на приём body исходя из длины body и сценария наихудшего канала связи, например 1024 кб/c.

Примерно такие же правила и на отправку.

И если таймауты выходят socket клиента закрывается и удаляется. Для socket вызывается shutdown и close. Всё как и предписывает microsoft.

Поставил таймауты для resive/transmite 2000ms. Не помогло. Идём далее.

Ttl

Что это за параметр? Wiki говорит:

Получает или задает значение, задающее время существования (TTL) IP-пакетов

Т.е. при прохождении очередного шлюза параметр уменьшается на 1. При достижении 0 пакет удаляется. И вроде это не наш случай. Потому как наша система вся на localhost. Но! При гугление я нашёл следующую информацию.

Там было сказано, что windows от этого параметра рассчитывает двойное время нахождения socket в режиме TIME_WAIT. Про этот режим более подробно ниже.

Поставил ttl в минимально возможное значение. Не помогло.

Утечка sockets?

Далее я подумал сервер открывает каждую секунду около 6000 тысяч sockets и закрывает их. И всё это крутится на кучи асинхронных Task. Вдруг количество открытых сокетов и закрытых не совпадает? И есть, некая утечка sockets?

Быстро набросав код принцип которого заключается в инкременте глобальной переменной при открытии socket и декременте при закрытии и вывода этого числа в консоль.

Весь дополнительный код состоял из 3 блоков:

Interlocked.Increment(ref Program.cnt);Interlocked.Decrement(ref Program.cnt);Task.Run(async () => { while (true) {await Task.Delay(1000);Console.WriteLine(Program.cnt); });}

Разумеется, каждый блок размещается в нужном месте.

Запустив программы, я получил следующее:

Видео можно разделить на три части. Запуск. Рабочее состояние длится около 3 минут. В этом состоянии ни одной ошибки. И первое появление ошибки. Собственно, вторую часть я вырезал как не интересную.

Как же нам интерпретировать результат? Во-первых, мы видим число примерно 400 выводящееся каждую секунду.

Как работает веб сервер? Идеальный сервер приняв клиента мгновенно формирует ответ, отправляет его клиенту и мгновенно закрывает socket (хотя в реальности socket не закрывается а ожидает следующих запросов, ну условимся что так работает идеальный сервер). Поэтому количество открытых socket в идеальном сервере каждую секунду было бы 0. Но тут мы видим, что каждую секунду у нас примерно 400 обрабатывающихся клиентов. Что ж, для неидеального сервера вполне норма. Вообще количество одновременных клиентов в нашем сервере задаётся глобальной настройкой. В данном случае 10000 что значительно выше 400.

Так же мы видим, что периодически подпрыгивающее значение до 1000-2000. Связанно это может быть с чем угодно. При желании можно и это выяснить. Может сборщик мусора, может что ещё. Но, собственно, ничего криминального в этом нет.

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

Главная цель текущего эксперимента сопоставить количество открытых и закрытых socket. Окончательная цифра 0. Всё, как и должно быть. Ладно идём дальше.

Динамический диапазон портов

Далее расследование завело меня в область настроек windows. Должны же быть какие-то настройки для работы tcp/ip стека? Гугление мне подкинуло множество, но особо я хотел бы остановится на наиболее подходящих к нашему случаю.

Собственно настройки tcp/ip стека для windows меня интересовали с самого начала. Я задавал себе вопросы - а какие вообще порты выделяются на клиенте? Да и количество портов как бы ограничено. Всего 65535 значений. Такое число обусловлено исторически переменной uint16 в протоколе TCP.

Если выделять тысячи портов в секунду то можно и исчерпать их всех.

Я даже вначале проверил следующий кейс. Изначально в httpdos использовался стандартный для .net способ открытия socket клиента.

new TcpClient().Connect("127.0.0.1", 80);

Такой способ выдаёт socket с портом из динамического пула windows. Т.е. windows сама решает какой порт тебе выдать. Я поменял этот код на другой где я сам указывал порты. Но первый запуск показал, что диапазон выбранных мною портов был нашпигован как изюм открытыми портами, плюс доступ к некоторым портам выдавал странные ошибки о некорректном доступе. Это заставляло меня усложнять программу делать код поиска открытых портов

Поискав готовое решение я увидел, что это не простой способ в одну строчку. И я не стал развивать эту тему дальше. Windows виднее кокой мне порт дать.

Но какой же диапазон портов мне выдаёт Windows? Тут я узнал ответ. Честно ещё в десятке других мест. Правда в большинстве информация была устаревшая - мол диапазон 1025 до 5000. Но я из практики знал, что диапазон выделяется где-то от 50000.

В дальнейшем я и убедился в своей правоте. Как оказалось Microsoft изменила этот диапазон, и он составляет 49152-65535. И того где-то 15k портов. Явно меньше, чем 65535

Поэтому первая настройка увеличиваем этот диапазон.

Я применил следующую команду:

netsh int ipv4 set dynamicport tcp start=10000 num=55535

Команду запускаем под администратором. Проверить значение можно командой:

netsh int ipv4 show dynamicport tcp

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

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

Добавляем код - 1 строка:

Console.WriteLine(((IPEndPoint)socket.RemoteEndPoint).Port);

Что показало нам запуск? При старте одной программы вылетела ошибка одного подключения. Не будем на это обращать внимание. А то так можно закопаться по уши в отладке. Дальше работало всё, как и ожидалось. Во-первых, мы чётко увидели, что диапазон выделяемых портов действительно стал 10000-65535. Во-вторых, порты выделяются последовательно без каких-то видимых провалов. Достигнув максимума, цикл повторяется. Я прогнал несколько циклов никаких отклонений.

Далее я начал закрывать программы и при закрытии второй программы серверный socket завис. Порты перестали выделятся. Во второй программе httpdos посыпались ошибки открытия socket.

Выводы проблема не в портах. По крайней мере порты явно не заканчиваются.

TIME WAIT

А что у нас есть вообще по протоколу TCP? Идём сюда и внимательно читаем.

Под подозрение попадает одно состояние TIME WAIT. Это одно из стояний, в котором может находится socket, т.е. пара ip+port. После его закрытия.

Получается, что мы закрыли socket но он ещё будет недоступен. Поиск показал, что это время находится в районе 4 минут.

Если бы мы играли в игру холодно-горячо, то я бы заорал горячо! Горячо! Похоже это именно наш случай.

Но зачем такое странное поведение. Опять же из справки получил пояснение. Т.к. протокол tcp/ip гуляет по сети пакетами, а сами пакеты могут пойти по разным маршрутам. И вообще застрять на некоторое время на каком-нибудь шлюзе, может получится ситуация, когда мы открыли socket, поработали с удалённым сервером. Закрыли socket. Потом этот socket открывает другая программа а ей начинают валится пакеты от предыдущей сессии. Как раз время таймаута и выбрано что бы отставшие все пакеты в сети удалились как мусорные.

OK, а можно ли изменить этот параметр? Оказывается в Windows есть целая ветка реестра, отвечающая за параметры tcp протокола.

HKEY_LOCAL_MACHINE \SYSTEM \CurrentControlSet \Services: \Tcpip \Parameters

Нас интересуют пока 2 параметра:

  1. TcpFinWait2Delay

  2. TcpTimedWaitDelay

Цель установить минимальное значение. Минимальное значение 30. Что означает что через 30 секунд порт опять будет доступен. Устанавливаем. Ну а далее наша стандартная проверка.

Запускаем сервер. Запускаем клиенты. Досим. Дожидаемся отказа socket. Потом запускаем консоль и вводим команду:

netstat -an

Такое ощущение что весь выделенный диапазон портов находится в состоянии TIME WAIT. Это пока соответствует ожиданиям. Ждём 30с. Повторяем команду. Листинг уменьшился в разы. Все порты с WAIT TIME пропали.

Проверяем серверный socket на оживление. Глух ((( А такие надежды были на эту настройку.

Впрочем, определённые выводы сделать можно. Настройка наша работает. Пауза 30с. Оставляем полезная настройка для нагруженного сервера.

Wireshark

Решил посмотреть, что нам покажет Wireshark. Кто не знает это мощнейший анализатор всевозможных протоколов, в том числе и tcp/ip. До недавнего времени в программе не была реализована функция прослушки localhost и приходилось производить некоторые танцы с бубном. Ставить виртуальную сетевую карту. Трафик пускать через неё. А Wireshark уже мог подключатся к прослушке этой карты.

Недавно подвезли возможность прослушки localhost что очень облегчило работу.

Далее всё стандартно. Запускаем сервер, клиентов. Убиваем слушающий socket.

Запускаем Wireshark ставим фильтр на 80 порт. Открываем браузер пытаемся закачать файл javascript.

Вот какое непотребство мы увидели в сниффере:

Анализируем увиденное.

Видно, наш запрос. Браузер с порта 36036 пытается достучатся к порту 80. Выставляет флаг SYN. Это стандартно. Но вот с порта 80 нам возвращается флаг RST оборвать соединения, сбросить буфер. Всё. И так по кругу.

Вывод. Wireshark нам особо не помощник. Разве только мы увидели, что слушающий socket не мёртв совсем, а отвечает. Он работает по какой-то своей внутренней логике, а не просто умер.

Журнал windows

Решил посмотреть может что в журнале событий windows есть что-то интересное. Для этого захожу в журнал.

Панель управления-> Администрирование-> Управление компьютером-> Служебные программы-> Просмотр событий-> Журналы Windows

Либо запустите eventvwr.msc

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

К сожалению, никаких событий, кусающихся моей проблемы, не появилось. Я даже нашёл такой раздел Microsoft/Windows/TCPIP. Но кроме одинокой записи работает, там ничего не было.

Финал?

Поиск в интернете всей доступной информации по проблеме периодически выдавал мне страницы подобной этим:

И ещё во многих других местах.

Отсюда я сделала 2 вывода я со соей проблемой не одинок, и как минимум периодически с ней сталкиваются и другие.

Есть ещё 2 параметра TCP стека с которыми можно поэкспериментировать.

  1. TcpNumConnections - максимальное количество одновременных подключений в системе.

  2. TcpMaxDataRetransmissions - количество повторных посылок при неудаче.

Поиск по этим параметрам для Windows 10 ничего не дал. Только для Windows 2003-2008 Server. Может плохо искал (наверняка). Но я всё же решил их проверить.

Установил следующие значения:

TcpNumConnections REG_DWORD: 00fffffe (hex)

TcpMaxDataRetransmissions REG_DWORD: 00000005 (hex)

Перезагрузился. Повторил в который раз все процедуры. И.

Socket жив. Всё проверив несколько раз, путём добавления и удаления из реестра параметров с последующей перезагрузкой компьютера. Все эксперименты показали, что с параметрами socket остаётся жив. Это всё?

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

Но.

На следующий день, я решил закрепить полученную информацию. Запускаю эксперимент socket мёртв. Мы вернулись к тому, с чего начинали!

Эти параметры оказались бесполезны в решении нашей проблемы. Но я их всё-таки оставил потому как по смыслу они полезные.

Продолжаем.

Финал.

Продолжая эксперименты, я обнаружил в коде один хитрый перехватчик исключений на слушающим socket. Точка остановки в этом месте показала, что при возникновении нашего случая сюда начали валится куча exception со следующим сообщением:

Удаленный хост принудительно разорвал существующее подключение.

В этом exсeption была паузу. С пояснением исключение на socket случай неординарный, например socket занят другим приложением, делаем паузу что бы всё утряслось и в лог не сыпалось миллионы сообщений.

Как оказалось не неординарный.

Картина сложилась.

Получается, что приёмный socket набрал большое число входящих подключений, которые оборвались, и больше не принимает новые подключения пока поток висел в exception на паузе.

Ради интереса провёл эксперимент. Убил socket. Ввёл команду netstat -an. И не увидел ни одного socket клиента на нашем socket сервера. Хотя в приёмном socket сервера висело куча мёртвых подключений.

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

Выводы.

Ну что ж. Я, конечно, ожидал что геройски одержу победу над некоей эпичной ошибкой. А всё оказалось очень банально. Собственно, как всегда.

Все эксперименты у меня заняли где-то полтора дня. Эту статью я писал намного больше.

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

Узнал новую информацию по поводу работы сети эти знания пригодятся впоследствии при решения очередного непонятного бага.

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

Опять же было неправильное представление, что серверный socket работает в отдельном потоке. И в любом случае должен принимать клиентов. Поэтому я и скал проблему в настройках socket и tcp стека. Главная проблема я думал поток висит на socket. А в нашем аварийном случае он висел на exeption в Delay.

Подробнее..

Перевод Блеск и нищета open source платформы RawCMS. Причины провала и выводы

23.04.2021 18:05:42 | Автор: admin

Я люблю открытое ПО. Я начал разрабатывать сторонний проект с открытым исходным кодом в 2006 году, и это был секрет развития моей карьеры. Благодаря моим экспериментам того времени, надеюсь, я вырос как разработчик и возвращаю что-то сообществу Open Source. По-моему мнению, открытый исходный код это драйвер роста компаний и разработчиков. И сегодня я хочу рассказать о своём опыте начавшейся в 2018 году работы над платформой low-code с открытым исходным кодом под названием RawCMS.


Провал

RawCMS начиналась как сторонний проект по улучшению будущего Asp.net core 3.1 и изучению возможности работы с неструктурированными данными для ускорения процесса разработки.

Во время разработки я столкнулся со многими проблемами и, наконец, пришёл к работающему доказательству концепции. Результат был колоссальным, и я должен много раз поблагодарить всех его участников (а их более 20).

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

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

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

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

Вся эта сложность пугала разработчиков на подходе.

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

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

Окончательный вывод: для наших нужд мы выбрали худшую технологию.

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

Как я смог за два дня сделать то же, что за два года?

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

Подходящий инструмент

Я не опоздал с .Net. Работаю с платформой с 2005 года, реализовал множество крупных корпоративных проектов, и меня приглашали на множество конференций, чтобы я поделился своим опытом.

Но я просто подобрал не тот инструмент для стороннего проекта. Я по-прежнему считаю, что .Net вероятно, лучший фреймворк для корпоративных приложений, а язык C# не имеет себе равных, с точки зрения производительности и впечатлений от разработки.

Проблема заключалась вот в чём: я выбрал .NET только потому, что это был фреймворк, в котором я чувствую себя более уверенно. Для меня выбор .Net был возможностью успеха. И я ошибался.

С тех пор как я начал этот проект, мне пришлось справляться с некоторыми функциями, которые .Net не поддерживает нативно; каждая отдельная проблема была преодолением.Я говорю об управлении нетипизированными данными (в частности маппинг и GraphQL), имея плагинную архитектуру и встраивая в неё модульный SPA.

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

Упорствовать вошибке от лукавого

Ошибки от человека, но упорство в ошибке от лукавого. Пытаясь заставить всё работать, я понял это на собственном опыте. Для меня это был важный урок.Сдаться перед огромной проблемой не всегда проявление слабости, а иногда, может быть, доказательство того, что вы умны. Я не остановился перед трудностями и победил зверя, но что потом?

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

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

Спросите себя, в чём проблема в самой проблеме или в вас?

Ещё до кодинга расскажите, как видите продукт

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

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

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

Этого бы не произошло, если бы система была проще и была основана на популярном фреймворке, таком как Symphony, с сообществом, где разработчики смогли бы найти ответы на свои вопросы.

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

Выводы

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

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

И вот какую пользу я извлёк из этого опыта:

  • Многое узнал о современных технологиях, что умножает мою ценность как профессионала.

  • Был приглашён на множество международных конференций, чтобы прояснить то, чему научился.

  • Помог начинающим разработчикам сделать первый шаг.

  • Встретил много замечательных людей, которые, как и я, верят в дух открытого исходного кода.

  • Вернул что-то сообществу открытого ПО.

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

Но ценный опыт можно получить и проще, например прокачав себя в разработке на направлении C#-разработчик. На нём можно не только повысить свою квалификацию, но и пообщаться с опытными менторами, которые разъяснят непонятные моменты.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Как перестать DDoS-ить чужой API и начать жить

25.04.2021 12:10:20 | Автор: admin

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

Вводные

Для начала немного вводных. Есть наше приложение иесть некий внешний сервис. Например, какое-то банковское ПО, API для отслеживания почтовых отправлений, что угодно. При этом наше приложение непросто использует его, там куча очень важной для нас информации. Прибыль компании напрямую зависит отобъема выгруженных оттуда данных. Мыпонимаем, один сервер это слишком мало изаводим себе пару десятков машин. Чтобы приложение масштабировалось лучше, делаем так: разбиваем весь объем намаленькие задачи иотправляем ихвочередь. Каждый сервер извлекает ихоттуда поодной. Втаком сообщении указан, например, IDпользователя. Затем приложение скачивает данные для него исохраняет ихвбазе. Большая ибыстрая машина обработает много задач, маленькая имедленная поменьше.

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

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

Один семафор на машину

Делим лимит запросов начисло доступных серверов (1000/20) иполучаем по50конкурентных обращений намашину.

Простой семафор в .NET
private const int RequestsLimit = 50;private static readonly SemaphoreSlim Throttler =   new SemaphoreSlim(RequestsLimit);async Task<HttpResponseMessage> InvokeServiceAsync(HttpClient client){try{await Throttler.WaitAsync().ConfigureAwait(false);return await client.GetAsync("todos/1").ConfigureAwait(false);}finally{Throttler.Release();}}

В .NET Core можно сделать типизированный HttpClient, получится очень вдухе новых веяний, янебуду останавливаться наэтом подробнее, новыможете посмотреть сюда. Там ивцелом такой подход раскрывается детальнее, чем яделаю это здесь.

Попробуем проанализировать то, что получилось.

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

Подведем ему некий итог:

Плюсы:

  1. Простой код

  2. Ресурсы машины используются эффективно

Минусы:

  1. Не полностью утилизируется канал во внешний сервис

Один семафор на всех

Подумаем, вкакую сторону двинуться теперь. Попробуем сделать семафор общим ресурсом для всех серверов. Так тоже без проблем необойдется будет нужно тратить время перед каждым запросом вовнешнюю систему наобращение ксервису-throttler-у. Ноадминистрировать такое, наверное, проще засостоянием одного семафора легче следить, ненадо подбирать лимит под каждую машину вотдельности. Какже сделать общий троттлер? Ну, конечноже вRedis.

Сточки зрения пользователя Redis однопоточный (онтак выглядит). Это круто, большая часть проблем сконкурентным доступом кнему сразу снимается.

ВRedis нет готового семафора, номожно построить его насортированных множествах.

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

Теперь подумаем, что писать. Учтём, что машина, взявшая блокировку, может никогда еёнеотпустить. Вдруг кто-то решит выключить рубильник или что-то пойдет непоплану вмомент обновления нашего приложения. Учтём также, что унас может быть несколько таких семафоров. Это пригодится, например, для разных внешних сервисов, API-ключей или если мызахотим выделить отдельный канал под приоритетные запросы.

Скрипт для Redis
--[[  KEYS[1] - Имя семафора ARGV[1] - Время жизни блокировки ARGV[2] - Идентификатор блокировки, чтобы её можно было возвратить ARGV[3] - Доступный объем семафора ]]--   -- Будем использовать команды с недетерминированным результатом,  -- Redis-у важно знать заранее redis.replicate_commands()local unix_time = redis.call('TIME')[1]   -- Удаляем блокировки с истёкшим TTL redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', unix_time - ARGV[1])   -- Получаем число элементов в множестве local count = redis.call('zcard', KEYS[1])   if count < tonumber(ARGV[3]) then-- добавляем блокировку в множество, если есть место  -- время будет являться ключем сортировки (для последующий чистки записей) redis.call('ZADD', KEYS[1], unix_time, ARGV[2])       -- Возвращаем число взятых блокировок (например, для логирования)    return count end   return nil

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

Подробнее о вариантах реализации блокировок с Redis и семафоров в частности можно посмотреть здесь.

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

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

Код для приложения
public sealed class RedisSemaphore{private static readonly string AcquireScript = "...";private static readonly int TimeToLiveInSeconds = 300;private readonly Func<ConnectionMultiplexer> _redisFactory;public RedisSemaphore(Func<ConnectionMultiplexer> redisFactory){_redisFactory = redisFactory;}public async Task<LockHandler> AcquireAsync(string name, int limit){var handler = new LockHandler(this, name);do{var redisDb = _redisFactory().GetDatabase();var rawResult = await redisDb.ScriptEvaluateAsync(AcquireScript, new RedisKey[] { name },new RedisValue[] { TimeToLiveInSeconds, handler.Id, limit }).ConfigureAwait(false);var acquired = !rawResult.IsNull;if (acquired)break;await Task.Delay(10).ConfigureAwait(false);} while (true);return handler;}public async Task ReleaseAsync(LockHandler handler, string name){var redis = _redisFactory().GetDatabase();await redis.SortedSetRemoveAsync(name, handler.Id).ConfigureAwait(false);}}public sealed class LockHandler : IAsyncDisposable{private readonly RedisSemaphore _semaphore;private readonly string _name;public LockHandler(RedisSemaphore semaphore, string name){_semaphore = semaphore;_name = name;Id = Guid.NewGuid().ToString();}public string Id { get; }public async ValueTask DisposeAsync(){await _semaphore.ReleaseAsync(this, _name).ConfigureAwait(false);}}

Посмотрим, что получилось.

Плюсы:

  1. Просто конфигурировать лимит

  2. Канал используется эффективно

  3. Легко наблюдать за утилизацией канала

Минусы:

  1. Дополнительный элемент инфраструктуры

  2. Ещё одна точка отказа

  3. Накладные расходы на обращение к Redis-у

  4. Нетривиальный код

Если выиспользуете Redis впроекте, топочемубы несделать нанём иблокировки. Тащитьже его ксебе только ради этого уже нетак весело. Что-то подобное можно реализовать, используя базу данных, носкорее всего это добавит немало хлопот инакладных расходов. Второй минус лично мне не кажется очень существенным. Что ни говори, а отказывает Redis обычно не так уж часто, особенно, если это SaaS.

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

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

Подробнее..

Из песочницы Как скрестить Excel c интерактивным веб-приложением

07.10.2020 16:09:34 | Автор: admin
Не секрет, что Excel довольно мощный инструмент для работы с числовыми табличными данными. Однако средства, которые предоставляет Microsoft для интеграции с ним, далеки от идеала. В частности, сложно интегрировать в Excel современные пользовательские интерфейсы. Нам нужно было дать пользователям Excel возможность работать с довольно насыщенным и функциональным интерфейсом. Мы пошли несколько другим путем, который в итоге показал хороший результат. В этой статье я расскажу, как можно организовать интерактивное взаимодействие Excel c веб-приложением на Angular и расшить Excel практически любым функционалом, который реализуем в современном веб-приложении.



Итак, меня зовут Михаил и я CTO в Exerica. Одна из проблем которые мы решаем облегчение работы финансовых аналитиков с числовыми данными. Обычно они работают как с исходными документами финансовой и статистической отчетности, так и каким-либо инструментом для создания и поддержания аналитических моделей. Так сложилось, что 99% аналитиков работают в Microsoft Excel и делают там довольно сложные вещи. Поэтому перевести их с Excel на другие решения не эффективно и практически невозможно. Объективно, облачные сервисы электронных таблиц до функционала Excel пока не дотягивают. Но в современном мире инструменты должны быть удобны и соответствовать ожиданиям пользователей: открываться по клику мышки, иметь удобный поиск. А реализация в виде разных несвязанных приложений будет довольно далека от ожданий пользователя.

То с чем работает аналитик выглядит примерно так:



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

Что у нас уже было


К моменту, когда мы начали реализацию интерактивного взаимодействия с Excel в виде, изложенном в этой статье, у нас уже была база данных на MongoDB, бэкэнд в виде REST API на .NET Core, фронтовое SPA на Angular и некоторые другие сервисы. Мы к этому моменту уже пробовали разные варианты интеграции в приложения электронных таблиц, в том числе и в Excel, и все они не пошли дальше MVP, но это тема отдельной статьи.



Связываем данные


В Excel существует два распространенных инструмента, с помощью которых можно решить задачу связывания данных в таблице с данными в системе: RTD (RealTimeData) и UDF (User-Defined Functions). Чистый RTD менее удобен для пользователя в плане синтаксиса и ограничивает гибкость решения. С помощью UDF можно создать кастомную функцию, которая будет работать привычным для Excel-пользователя образом. Ее можно использовать в других функциях, она понимает ссылки типа A1 или R1C1 и вообще ведет себя как надо. При этом никто не мешает использовать механизм RTD для обновления значения функции (что мы и сделали). UDF мы разрабатывали в виде Excel addin с использованием привычного нам C# и .NET Framework. Для ускорения разработки мы использовали библиотеку Excel DNA.

Кроме UDF наш addin реализует ribbon (панель инструментов) с настройками и некоторыми полезными функциями по работе с данными.

Добавляем интерактивность


Для передачи данных в Excel и налаживания интерактива мы разработали отдельный сервис, который предоставляет подключение по Websocket при помощи библиотеки SignalR и фактически является брокером для сообщений о событиях, которыми должны обмениваться фронтовые части системы в реальном времени. Он у нас называется Notification Service.



Вставляем данные в Excel


В нашем SPA мы подсвечиваем все числа, которые обнаружила система. Пользователь может выделять их, навигировать по ним и т.п. Для вставки данных мы реализовали 3 механизма, чтобы закрыть различные варианты использования:

  • Перетаскивание (drag-and-drop)
  • Автоматическая вставка по клику в SPA
  • Копирование и вставка через клипборд

Когда пользователь инициирует dragndrop некоторого числа из SPA, для перетаскивания формируется ссылка с идентификатором этого числа из нашей системы (.../unifiedId/005F5549CDD04F8000010405FF06009EB57C0D985CD001). При вставке в Excel наш addin перехватывает событие вставки и парсит регэкспом вставляемый текст. При обнаружении валидной ссылки на лету подменяет ее на соответствующую формулу =ExrcP(...).

При клике на числе в SPA через Notification Service отправляется сообщение в addin, содержащее все необходимые данные для вставки формулы. Далее формула просто вставляется в текущую выделенную ячейку.

Эти способы хороши, когда пользователю нужно вставлять в свою модель по одному числу, но если надо перенести целую таблицу или ее часть, необходим другой механизм. Наиболее привычным для пользователей представляется копирование через клипборд. Однако этот способ оказался сложнее первых двух. Дело в том, что для удобства вставляемые данные должны быть представлены в нативном для Excel формате OpenXML Spreadsheet. Наиболее просто это реализуется используя объектную модель Excel, то есть из addinа. Поэтому процесс формирования клипборда у нас выглядит так:

  • Пользователь выделяет область с числами в SPA
  • Массив выделенных чисел передается на Notification Service
  • Notification Service передает его в addin
  • Addin формирует OpenXML и вставляет его в клипборд
  • Пользователь может вставить данные из клипборда в любое место любой Excel-таблицы.



Несмотря на то, что данные проделывают довольно долгий путь, благодаря SignalR и RTD происходит это довольно быстро и абстрагированно от пользователя.

Распространяем данные


После того, как пользователь выбрал начальные данные для своей модели, их надо распространить все периоды (года, полугодия и кварталы), которые представляют интерес. Для этих целей одним из параметров нашей UDF является дата (период) данного числа (вспоминаем: доход за 1 квартал 2020 года). В Excel существует нативный механизм распространения формул, который позволяет заполнить ячейки той же формулой с учетом ссылок, заданных в параметрах. То есть вместо конкретной даты в формулу вставлена ссылка на нее, а далее пользователь распространяет ее на другие периоды, при этом в таблицу автоматически загружаются те же числа из других периодов.

А что это там за число?


Теперь у пользователя есть модель на несколько сотен строк и несколько десятков столбцов. И у него может возникнуть вопрос, что же там за число в ячейке L123? Чтобы получить ответ, у нас ему достаточно кликнуть на эту ячейку и в нашем SPA откроется тот самый отчет, на той самой странице, где записано кликнутое число, а число в отчете будет выделено. Вот так:



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

В качестве заключения


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

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

ASP.NET Core MVC WebAPI Entity Framework Microsoft SQL Server Angular

09.08.2020 20:18:26 | Автор: admin


Введение


Небольшой курс по созданию простого веб-приложения с помощью технологий ASP.NET Core MVC, фреймворка Entity Framework, СУБД Microsoft SQL Server и фреймворка Angular. Тестировать web API будем через приложение Postman.

Курс состоит из нескольких частей:
  1. Создание web API с помощью ASP.NET Core MVC и Entity Framework Core.
  2. Реализация пользовательского интерфейса на Angular.
  3. Добавление аутентификации в приложение.
  4. Расширение модели приложения и рассмотрение дополнительных возможностей Entity Framework.


Часть 1. Создание web API с помощью ASP.NET Core MVC и Entity Framework Core


В качестве примера будем расматривать уже ставшее классическим приложение списка дел. Для разработки приложения я буду использовать Visual Studio 2019(в Visual Studio 2017 процесс аналогичен).

Создание проекта


Создадим новый проект ASP.NET Core Web Application в Visual Studio:



Назовем приложение и укажем путь к каталогу с проектом:



И выберем шаблон приложения API:



Модель


Создадим каталог Models и в новый каталог добавим первый класс TodoItem.cs, объекты которого будут описывать некоторые задачи списка дел в приложении:

public class TodoItem{    public int Id { get; set; }    public string TaskDescription { get; set; }    public bool IsComplete { get; set; }}


В качестве СУБД мы будем использовать Sql Server, а доступ к базе данных будет осуществляться через Entity Framework Core и для начала установим фреймворк через встроенный пакетный менеджер NuGet:



Одним из подходов в работе с Entity Framework является подход Code-First. Суть подхода заключается в том, что на основе модели приложения(в нашем случае модель представляет единственный класс TodoItem.cs) формируется струткура базы данных(таблицы, первичные ключи, ссылки), вся эта работа происходит как бы за кулисами и напрямую с SQL мы не работаем. Обязательным условием класса модели является наличие поля первичного ключа, по умолчанию Entity Framework ищет целочисленное поле в имени которого присутствует подстрока id и формирует на его основе первичный ключ. Переопределить такое поведение можно с помощью специальных атрибутов или используя возможности Fluent API.
Главным компонентом в работе с Entity Framework является класс контекста базы данных, через который собственно и осуществляется доступ к данным в таблицах:

public class EFTodoDBContext : DbContext{    public EFTodoDBContext(DbContextOptions<EFTodoDBContext> options) : base(options)     { }    public DbSet<TodoItem> TodoItems{ get; set; }}


Базовый класс DbContext создает контекст БД и обеспечивает доступ к функциональности Entity Framework.
Для хранения данных приложения мы будем использовать SQL Server 2017 Express. Строки подключения хранятся в файле JSON под названием appsettings.json:

{  "ConnectionStrings": {    "DefaultConnection": "Server=.\\SQLEXPRESS;Database=Todo;Trusted_Connection=true"  }}


Далее нужно внести изменения в класс Startup.cs, добавив в метод ConfigureServices() следующий код:

services.AddDbContext<EFTodoDBContext>(options => options.UseSqlServer(Configuration["ConnectionStrings:DefaultConnection"]));


Метод AddDbContext() настраивает службы, предоставляемые инфраструктурой Entity Framework Core для класса контекста базы EFTodoDBContext. Аргументом метода AddDbContext () является лямбда-выражение, которое получает объект options, конфигурирующий базу данных для класса контекста. В этом случае база данных конфигурируется с помощью метода UseSqlServer() и указания строки подключения.
Определим основные операции для работы с задачами в интерфейсе ITodoRepository:

 public interface ITodoRepository {    IEnumerable<TodoItem> Get();    TodoItem Get(int id);    void Create(TodoItem item);    void Update(TodoItem item);    TodoItem Delete(int id); }


Данный интерфейс позволяет нам не задумываться о конкретной реализации хранилища данных, возможно мы точно не определились с выбором СУБД или ORM фреймворком, сейчас это не важно, класс описывающий доступ к данным будет наследовать от этого интерфейса.
Реализуем репозиторий, который как уже сказано ранее, будет наследовать от ITodoRepository и использовать в качестве источника данных EFTodoDBContext:

public class EFTodoRepository : ITodoRepository{    private EFTodoDBContext Context;    public IEnumerable<TodoItem> Get()    {        return Context.TodoItems;    }    public TodoItem Get(int Id)    {        return Context.TodoItems.Find(Id);    }    public EFTodoRepository(EFTodoDBContext context)    {        Context = context;    }    public void Create(TodoItem item)    {        Context.TodoItems.Add(item);        Context.SaveChanges();    }    public void Update(TodoItem updatedTodoItem)    {        TodoItem currentItem = Get(updatedTodoItem.Id);        currentItem.IsComplete = updatedTodoItem.IsComplete;        currentItem.TaskDescription = updatedTodoItem.TaskDescription;        Context.TodoItems.Update(currentItem);        Context.SaveChanges();        }    public TodoItem Delete(int Id)    {        TodoItem todoItem = Get(Id);        if (todoItem != null)        {            Context.TodoItems.Remove(todoItem);            Context.SaveChanges();        }        return todoItem;    }    }


Контроллер


Контроллер, реализация которого будет описана ниже, ничего не будет знать о контексте данных EFTodoDBContext, а будет использовать в своей работе только интерфейс ITodoRepository, что позволяет изменить источник данных не меняя при этом контроллера. Такой подход Адам Фримен в своей книге Entity Framework Core 2 для ASP.NET Core MVC для профессионалов назвал паттерн Хранилище.
Контроллер реализует обработчики стандартных методов HTTP-запросов: GET, POST, PUT, DELETE, которые будут изменять состояние наших задач, описанных в классе TodoItem.cs. Добавим в каталог Controllers класс TodoController.cs со следующим содержимым:

[Route("api/[controller]")]public class TodoController : Controller{    ITodoRepository TodoRepository;    public TodoController(ITodoRepository todoRepository)    {        TodoRepository = todoRepository;    }    [HttpGet(Name = "GetAllItems")]    public IEnumerable<TodoItem> Get()    {        return TodoRepository.Get();    }    [HttpGet("{id}", Name = "GetTodoItem")]    public IActionResult Get(int Id)    {        TodoItem todoItem = TodoRepository.Get(Id);        if (todoItem == null)        {            return NotFound();        }        return new ObjectResult(todoItem);    }    [HttpPost]    public IActionResult Create([FromBody] TodoItem todoItem)     {        if (todoItem == null)        {            return BadRequest();        }        TodoRepository.Create(todoItem);        return CreatedAtRoute("GetTodoItem", new { id = todoItem.Id }, todoItem);    }    [HttpPut("{id}")]    public IActionResult Update(int Id, [FromBody] TodoItem updatedTodoItem)    {        if (updatedTodoItem == null || updatedTodoItem.Id != Id)        {            return BadRequest();        }        var todoItem = TodoRepository.Get(Id);        if (todoItem == null)        {            return NotFound();        }        TodoRepository.Update(updatedTodoItem);        return RedirectToRoute("GetAllItems");    }    [HttpDelete("{id}")]    public IActionResult Delete(int Id)    {        var deletedTodoItem = TodoRepository.Delete(Id);        if (deletedTodoItem == null)        {            return BadRequest();        }        return new ObjectResult(deletedTodoItem);    } }


Перед определением класса указан атрибут с описанием шаблона маршрута для доступа к контроллеру: [Route(api/[controller])]. Контроллер TodoController будет доступен по следующему маршруту: https://<ip хоста>:<порт>/api/todo. В [controller] указывается название класса контроллера в нижнем регистре, опуская часть Controller.
Перед определением каждого метода в контроллере TodoController указан специальный атрибут вида: [<метод HTTP>(параметр,Name = псевдоним метода)]. Атрибут определяет какой HTTP-запрос будет обработан данным методом, параметр, который передается в URL запроса и псевдоним метода с помощью которого можно переотправлять запрос. Если не указать атрибут, то по умолчанию инфраструктура MVC попытается найти самый подходящий метод в контроллере для обработки запроса исходя из названия метода и указанных параметров в запросе, так, если не указать в контроллере TodoController атрибут для метода Get(), то при HTTP-запросе методом GET: https://<ip хоста>:<порт>/api/todo, инфраструткура определит для обработки запроса метод Get() контроллера.
В своем конструкторе контроллер получает ссылку на объект типа ITodoRepository, но пока что инфраструктура MVC не знает, какой объект подставить при создании контроллера. Нужно создать сервис, который однозначно разрешит эту зависисмость, для этого внесем некотрые изменения в класс Startup.cs, добавив в метод ConfigureServices() следующий код:

services.AddTransient<ITodoRepository, EFTodoRepository>();


Метод AddTransient<ITodoRepository, EFTodoRepository>() определяет сервис, который каждый раз, когда требуется экземпляр типа ITodoRepository, например в контроллере, создает новый экземпляр класс EFTodoRepository.
Полный код класса Startup.cs:

public class Startup{    public Startup(IConfiguration configuration)    {        Configuration = configuration;    }    public IConfiguration Configuration { get; }    public void ConfigureServices(IServiceCollection services)    {        services.AddControllers();        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0);        services.AddDbContext<EFTodoDBContext>(options => options.UseSqlServer(Configuration["ConnectionStrings:DefaultConnection"]));        services.AddTransient<ITodoRepository, EFTodoRepository>();    }    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)    {        if (env.IsDevelopment())        {            app.UseDeveloperExceptionPage();        }        app.UseHttpsRedirection();        app.UseRouting();        app.UseAuthorization();        app.UseEndpoints(endpoints =>        {            endpoints.MapControllers();        });    } }


Миграции


Для того чтобы Entity Framework сгенерировал базу данных и таблицы на основе модели, нужно использовать процесс миграции базы данных. Миграции это группа команд, которая выполняет подготовку базы данных для работы с Entity Framework. Они используются для создания и синхронизации базы данных. Команды можно выполнять как в консоли диспетчера пакетов (Package Manager Console), так и в Power Shell(Developer Power Shell). Мы будем использовать консоль диспетчера пакетов, для работы с Entity Framework потребуется установить пакет Microsoft.EntityFrameworkCore.Tools:



Запустим консоль диспетчера пакетов и выполним команду Add-Migration Initial:





В проекте появится новый каталог Migrations, в котором будут хранится классы миграции, на основе которых и будут создаваться объекты в базе данных после выполнения команды Update-Database:



Web API готово, запустив приложение на локальном IIS Express мы можем протестировать работу контроллера.

Тестирование WebAPI


Создадим новую коллекцию запросов в Postman под названием TodoWebAPI:



Так как наша база пуста, протестируем для начала создание новой задачи. В контроллере за создание задач отвечает метод Create(), который будет обрабатывать HTTP запрос отправленный методом POST и будет содержать в теле запроса сериализированный объект TodoItem в JSON формате. Аттрибут [FromBody] перед параметром todoItem в методе Create() подсказывает инфраструктуре MVC, что нужно десериализировать объект TodoItem из тела запроса и передать его в качестве параметра методу. Создадим запрос в Postman, который отправит на webAPI запрос на создание новой задачи:



Метод Create() после успешного создания задачи перенаправляет запрос на метод Get() с псевдонимом GetTodoItem и передает в качестве параметра Id только что созданной задачи, в результате чего в ответ на запрос мы получим созданный объект задачи в формате JSON.

Отправив HTTP запрос методом PUT и указав при этом в URL Id(http://personeltest.ru/aways/localhost:44370/api/todo/1) уже созданного объекта, а в теле запроса передав объект с некоторыми изменениями в формате JSON, мы изменим этот объект в базе:



HTTP запросом с методом GET без указания параметров получим все объекты в базе:



Запрос HTTP с методом DELETE и указанием Id объекта в URL(http://personeltest.ru/aways/localhost:44370/api/todo/2), удалит объект из базы и вернет JSON с удаленной задачей:



На этом все, в следующей части реализуем пользовательский интерфейс с помощью JavaScript-фреймворка Angular.
Подробнее..

ASP.NET Core MVC WebAPI Entity Framework Microsoft SQL Server Angular. Часть 1

09.08.2020 22:07:12 | Автор: admin


Введение


Небольшой курс по созданию простого веб-приложения с помощью технологий ASP.NET Core MVC, фреймворка Entity Framework, СУБД Microsoft SQL Server и фреймворка Angular. Тестировать web API будем через приложение Postman.

Курс состоит из нескольких частей:
  1. Создание web API с помощью ASP.NET Core MVC и Entity Framework Core.
  2. Реализация пользовательского интерфейса на Angular.
  3. Добавление аутентификации в приложение.
  4. Расширение модели приложения и рассмотрение дополнительных возможностей Entity Framework.


Часть 1. Создание web API с помощью ASP.NET Core MVC и Entity Framework Core


В качестве примера будем расматривать уже ставшее классическим приложение списка дел. Для разработки приложения я буду использовать Visual Studio 2019(в Visual Studio 2017 процесс аналогичен).

Создание проекта


Создадим новый проект ASP.NET Core Web Application в Visual Studio:



Назовем приложение и укажем путь к каталогу с проектом:



И выберем шаблон приложения API:



Модель


Создадим каталог Models и в новый каталог добавим первый класс TodoItem.cs, объекты которого будут описывать некоторые задачи списка дел в приложении:

public class TodoItem{    public int Id { get; set; }    public string TaskDescription { get; set; }    public bool IsComplete { get; set; }}


В качестве СУБД мы будем использовать Sql Server, а доступ к базе данных будет осуществляться через Entity Framework Core и для начала установим фреймворк через встроенный пакетный менеджер NuGet:



Одним из подходов в работе с Entity Framework является подход Code-First. Суть подхода заключается в том, что на основе модели приложения(в нашем случае модель представляет единственный класс TodoItem.cs) формируется струткура базы данных(таблицы, первичные ключи, ссылки), вся эта работа происходит как бы за кулисами и напрямую с SQL мы не работаем. Обязательным условием класса модели является наличие поля первичного ключа, по умолчанию Entity Framework ищет целочисленное поле в имени которого присутствует подстрока id и формирует на его основе первичный ключ. Переопределить такое поведение можно с помощью специальных атрибутов или используя возможности Fluent API.
Главным компонентом в работе с Entity Framework является класс контекста базы данных, через который собственно и осуществляется доступ к данным в таблицах:

public class EFTodoDBContext : DbContext{    public EFTodoDBContext(DbContextOptions<EFTodoDBContext> options) : base(options)     { }    public DbSet<TodoItem> TodoItems{ get; set; }}


Базовый класс DbContext создает контекст БД и обеспечивает доступ к функциональности Entity Framework.
Для хранения данных приложения мы будем использовать SQL Server 2017 Express. Строки подключения хранятся в файле JSON под названием appsettings.json:

{  "ConnectionStrings": {    "DefaultConnection": "Server=.\\SQLEXPRESS;Database=Todo;Trusted_Connection=true"  }}


Далее нужно внести изменения в класс Startup.cs, добавив в метод ConfigureServices() следующий код:

services.AddDbContext<EFTodoDBContext>(options => options.UseSqlServer(Configuration["ConnectionStrings:DefaultConnection"]));


Метод AddDbContext() настраивает службы, предоставляемые инфраструктурой Entity Framework Core для класса контекста базы EFTodoDBContext. Аргументом метода AddDbContext () является лямбда-выражение, которое получает объект options, конфигурирующий базу данных для класса контекста. В этом случае база данных конфигурируется с помощью метода UseSqlServer() и указания строки подключения.
Определим основные операции для работы с задачами в интерфейсе ITodoRepository:

 public interface ITodoRepository {    IEnumerable<TodoItem> Get();    TodoItem Get(int id);    void Create(TodoItem item);    void Update(TodoItem item);    TodoItem Delete(int id); }


Данный интерфейс позволяет нам не задумываться о конкретной реализации хранилища данных, возможно мы точно не определились с выбором СУБД или ORM фреймворком, сейчас это не важно, класс описывающий доступ к данным будет наследовать от этого интерфейса.
Реализуем репозиторий, который как уже сказано ранее, будет наследовать от ITodoRepository и использовать в качестве источника данных EFTodoDBContext:

public class EFTodoRepository : ITodoRepository{    private EFTodoDBContext Context;    public IEnumerable<TodoItem> Get()    {        return Context.TodoItems;    }    public TodoItem Get(int Id)    {        return Context.TodoItems.Find(Id);    }    public EFTodoRepository(EFTodoDBContext context)    {        Context = context;    }    public void Create(TodoItem item)    {        Context.TodoItems.Add(item);        Context.SaveChanges();    }    public void Update(TodoItem updatedTodoItem)    {        TodoItem currentItem = Get(updatedTodoItem.Id);        currentItem.IsComplete = updatedTodoItem.IsComplete;        currentItem.TaskDescription = updatedTodoItem.TaskDescription;        Context.TodoItems.Update(currentItem);        Context.SaveChanges();        }    public TodoItem Delete(int Id)    {        TodoItem todoItem = Get(Id);        if (todoItem != null)        {            Context.TodoItems.Remove(todoItem);            Context.SaveChanges();        }        return todoItem;    }    }


Контроллер


Контроллер, реализация которого будет описана ниже, ничего не будет знать о контексте данных EFTodoDBContext, а будет использовать в своей работе только интерфейс ITodoRepository, что позволяет изменить источник данных не меняя при этом контроллера. Такой подход Адам Фримен в своей книге Entity Framework Core 2 для ASP.NET Core MVC для профессионалов назвал паттерн Хранилище.
Контроллер реализует обработчики стандартных методов HTTP-запросов: GET, POST, PUT, DELETE, которые будут изменять состояние наших задач, описанных в классе TodoItem.cs. Добавим в каталог Controllers класс TodoController.cs со следующим содержимым:

[Route("api/[controller]")]public class TodoController : Controller{    ITodoRepository TodoRepository;    public TodoController(ITodoRepository todoRepository)    {        TodoRepository = todoRepository;    }    [HttpGet(Name = "GetAllItems")]    public IEnumerable<TodoItem> Get()    {        return TodoRepository.Get();    }    [HttpGet("{id}", Name = "GetTodoItem")]    public IActionResult Get(int Id)    {        TodoItem todoItem = TodoRepository.Get(Id);        if (todoItem == null)        {            return NotFound();        }        return new ObjectResult(todoItem);    }    [HttpPost]    public IActionResult Create([FromBody] TodoItem todoItem)     {        if (todoItem == null)        {            return BadRequest();        }        TodoRepository.Create(todoItem);        return CreatedAtRoute("GetTodoItem", new { id = todoItem.Id }, todoItem);    }    [HttpPut("{id}")]    public IActionResult Update(int Id, [FromBody] TodoItem updatedTodoItem)    {        if (updatedTodoItem == null || updatedTodoItem.Id != Id)        {            return BadRequest();        }        var todoItem = TodoRepository.Get(Id);        if (todoItem == null)        {            return NotFound();        }        TodoRepository.Update(updatedTodoItem);        return RedirectToRoute("GetAllItems");    }    [HttpDelete("{id}")]    public IActionResult Delete(int Id)    {        var deletedTodoItem = TodoRepository.Delete(Id);        if (deletedTodoItem == null)        {            return BadRequest();        }        return new ObjectResult(deletedTodoItem);    } }


Перед определением класса указан атрибут с описанием шаблона маршрута для доступа к контроллеру: [Route(api/[controller])]. Контроллер TodoController будет доступен по следующему маршруту: https://<ip хоста>:<порт>/api/todo. В [controller] указывается название класса контроллера в нижнем регистре, опуская часть Controller.
Перед определением каждого метода в контроллере TodoController указан специальный атрибут вида: [<метод HTTP>(параметр,Name = псевдоним метода)]. Атрибут определяет какой HTTP-запрос будет обработан данным методом, параметр, который передается в URL запроса и псевдоним метода с помощью которого можно переотправлять запрос. Если не указать атрибут, то по умолчанию инфраструктура MVC попытается найти самый подходящий метод в контроллере для обработки запроса исходя из названия метода и указанных параметров в запросе, так, если не указать в контроллере TodoController атрибут для метода Get(), то при HTTP-запросе методом GET: https://<ip хоста>:<порт>/api/todo, инфраструткура определит для обработки запроса метод Get() контроллера.
В своем конструкторе контроллер получает ссылку на объект типа ITodoRepository, но пока что инфраструктура MVC не знает, какой объект подставить при создании контроллера. Нужно создать сервис, который однозначно разрешит эту зависисмость, для этого внесем некотрые изменения в класс Startup.cs, добавив в метод ConfigureServices() следующий код:

services.AddTransient<ITodoRepository, EFTodoRepository>();


Метод AddTransient<ITodoRepository, EFTodoRepository>() определяет сервис, который каждый раз, когда требуется экземпляр типа ITodoRepository, например в контроллере, создает новый экземпляр класс EFTodoRepository.
Полный код класса Startup.cs:

public class Startup{    public Startup(IConfiguration configuration)    {        Configuration = configuration;    }    public IConfiguration Configuration { get; }    public void ConfigureServices(IServiceCollection services)    {        services.AddControllers();        services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_3_0);        services.AddDbContext<EFTodoDBContext>(options => options.UseSqlServer(Configuration["ConnectionStrings:DefaultConnection"]));        services.AddTransient<ITodoRepository, EFTodoRepository>();    }    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)    {        if (env.IsDevelopment())        {            app.UseDeveloperExceptionPage();        }        app.UseHttpsRedirection();        app.UseRouting();        app.UseAuthorization();        app.UseEndpoints(endpoints =>        {            endpoints.MapControllers();        });    } }


Миграции


Для того чтобы Entity Framework сгенерировал базу данных и таблицы на основе модели, нужно использовать процесс миграции базы данных. Миграции это группа команд, которая выполняет подготовку базы данных для работы с Entity Framework. Они используются для создания и синхронизации базы данных. Команды можно выполнять как в консоли диспетчера пакетов (Package Manager Console), так и в Power Shell(Developer Power Shell). Мы будем использовать консоль диспетчера пакетов, для работы с Entity Framework потребуется установить пакет Microsoft.EntityFrameworkCore.Tools:



Запустим консоль диспетчера пакетов и выполним команду Add-Migration Initial:





В проекте появится новый каталог Migrations, в котором будут хранится классы миграции, на основе которых и будут создаваться объекты в базе данных после выполнения команды Update-Database:



Web API готово, запустив приложение на локальном IIS Express мы можем протестировать работу контроллера.

Тестирование WebAPI


Создадим новую коллекцию запросов в Postman под названием TodoWebAPI:



Так как наша база пуста, протестируем для начала создание новой задачи. В контроллере за создание задач отвечает метод Create(), который будет обрабатывать HTTP запрос отправленный методом POST и будет содержать в теле запроса сериализированный объект TodoItem в JSON формате. Аттрибут [FromBody] перед параметром todoItem в методе Create() подсказывает инфраструктуре MVC, что нужно десериализировать объект TodoItem из тела запроса и передать его в качестве параметра методу. Создадим запрос в Postman, который отправит на webAPI запрос на создание новой задачи:



Метод Create() после успешного создания задачи перенаправляет запрос на метод Get() с псевдонимом GetTodoItem и передает в качестве параметра Id только что созданной задачи, в результате чего в ответ на запрос мы получим созданный объект задачи в формате JSON.

Отправив HTTP запрос методом PUT и указав при этом в URL Id(http://personeltest.ru/aways/localhost:44370/api/todo/1) уже созданного объекта, а в теле запроса передав объект с некоторыми изменениями в формате JSON, мы изменим этот объект в базе:



HTTP запросом с методом GET без указания параметров получим все объекты в базе:



Запрос HTTP с методом DELETE и указанием Id объекта в URL(http://personeltest.ru/aways/localhost:44370/api/todo/2), удалит объект из базы и вернет JSON с удаленной задачей:



На этом все, в следующей части реализуем пользовательский интерфейс с помощью JavaScript-фреймворка Angular.
Подробнее..

Варианты использования конфигурации в ASP.NET Core

08.09.2020 02:17:37 | Автор: admin
Для получения конфигурации приложения обычно используют метод доступа по ключевому слову (ключ-значение). Но это бывает не всегда удобно т.к. иногда требуется использовать готовые объекты в коде с уже установленными значениями, причем с возможностью обновления значений без перезагрузки приложения. В данном примере предлагается шаблон использования конфигурации в качестве промежуточного слоя для ASP.NET Core приложений.

Предварительно рекомендуется ознакомиться с материалом: Metanit Конфигурация, Как работает конфигурация в .NET Core.

Постановка задачи


Необходимо реализовать ASP NET Core приложение с возможностью обновления конфигурации в формате JSON во время работы. Во время обновления конфигурации текущие работающие сессии должны продолжать работать с предыдущим вариантов конфигурации. После обновления конфигурации, используемые объекты должны быть обновлены/заменены новыми. Конфигурация должна быть десериализована, не должно быть прямого доступа к объектам IConfiguration из контроллеров. Считываемые значения должны проходить проверку на корректность, при отсутствии как таковых заменяться значениями по умолчанию. Реализация должна работать в Docker контейнере.

Классическая работа с конфигурацией


GitHub: ConfigurationTemplate_1
Проект основан на шаблоне ASP NET Core MVC. Для работы с файлами конфигурации JSON используется провайдер конфигурации JsonConfigurationProvider. Для добавления возможности перезагрузки конфигурации приложения, во время работы, добавим параметр: reloadOnChange: true.
В файле Startup.cs заменим:
public Startup(IConfiguration configuration) {   Configuration = configuration; }

На
public Startup(IConfiguration configuration) {            var builder = new ConfigurationBuilder()    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);   configuration = builder.Build();   Configuration = configuration;  }

.AddJsonFile добавляет JSON файл, reloadOnChange:true указывает на то, что при изменение параметров файла конфигурации, они будут перезагружены без необходимости перезагружать приложение.
Содержимое файла appsettings.json:
{  "AppSettings": {    "Parameter1": "Parameter1 ABC",    "Parameter2": "Parameter2 ABC"    },  "Logging": {    "LogLevel": {      "Default": "Information",      "Microsoft": "Warning",      "Microsoft.Hosting.Lifetime": "Information"    }  },  "AllowedHosts": "*"}

Контроллеры приложения вместо прямого обращения к конфигурации будут использовать сервис: ServiceABC. ServiceABC класс который первоначальные значения берет из файла конфигурации. В данном примере класс ServiceABC содержит только одно свойство Title.
Содержимое файла ServiceABC.cs:
public class ServiceABC{  public string Title;  public ServiceABC(string title)  {     Title = title;  }  public ServiceABC()  { }}

Для использования ServiceABC необходимо его добавить в качестве сервиса middleware в приложение. Добавим сервис как AddTransient, который создается каждый раз при обращении к нему, с помощью выражения:
services.AddTransient<IYourService>(o => new YourService(param));
Отлично подходит для легких сервисов, не потребляющих память и ресурсы. Чтение параметров конфигурации в Startup.cs осуществляется с помощью IConfiguration, где используется строка запроса с указанием полного пути расположения значения, пример: AppSettings:Parameter1.
В файле Startup.cs добавим:
public void ConfigureServices(IServiceCollection services){  //Считывание параметра "Parameter1" для инициализации сервиса ServiceABC  var settingsParameter1 = Configuration["AppSettings:Parameter1"];  //Добавление сервиса "Parameter1"              services.AddScoped(s=> new ServiceABC(settingsParameter1));  //next  services.AddControllersWithViews();}

Пример использования сервиса ServiceABC в контроллере, значение Parameter1 будет отображаться на html странице.
Для использования сервиса в контроллерах добавим его в конструктор, файл HomeController.cs
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly ServiceABC _serviceABC;  public HomeController(ILogger<HomeController> logger, ServiceABC serviceABC)    {      _logger = logger;      _serviceABC = serviceABC;    }  public IActionResult Index()    {      return View(_serviceABC);    }

Добавим видимость сервиса ServiceABC в файл _ViewImports.cshtml
@using ConfigurationTemplate_1.Services

Изменим Index.cshtml для отображение параметра Parameter1 на странице.
@model ServiceABC@{    ViewData["Title"] = "Home Page";}    <div class="text-center">        <h1>Десериализация конфигурации в ASP.NET Core</h1>        <h4>Классическая работа с конфигурацией</h4>    </div><div>            <p>Сервис ServiceABC, отображение Заголовка        из параметра Parameter1 = @Model.Title</p></div>

Запустим приложение:


Итог


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

Использование IConfiguration как Singleton


GitHub: ConfigurationTemplate_2
Второй вариант заключается в помещение IConfiguration(как Singleton) в сервисы. В результате IConfiguration может вызываться из контроллеров и других сервисов. При использовании AddSingleton сервис создается один раз и при использовании приложения обращение идет к одному и тому же экземпляру. Использовать этот способ нужно особенно осторожно, так как возможны утечки памяти и проблемы с многопоточностью.
Заменим код из предыдущего примера в Startup.cs на новый, где
services.AddSingleton<IConfiguration>(Configuration);
добавляет IConfiguration как Singleton в сервисы.
public void ConfigureServices(IServiceCollection services){  //Доступ к IConfiguration из других контроллеров и сервисов  services.AddSingleton<IConfiguration>(Configuration);  //Добавление сервиса "ServiceABC"                            services.AddScoped<ServiceABC>();  //next  services.AddControllersWithViews();}

Изменим конструктор сервиса ServiceABC для принятия IConfiguration
public class ServiceABC{          private readonly IConfiguration _configuration;  public string Title => _configuration["AppSettings:Parameter1"];          public ServiceABC(IConfiguration Configuration)    {      _configuration = Configuration;    }  public ServiceABC()    { }}

Как и в предыдущем варианте добавим сервис в конструктор и добавим ссылку на пространство имен
Для использования сервиса в контроллерах добавим его в конструктор, файл HomeController.cs
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly ServiceABC _serviceABC;  public HomeController(ILogger<HomeController> logger, ServiceABC serviceABC)    {      _logger = logger;      _serviceABC = serviceABC;    }  public IActionResult Index()    {      return View(_serviceABC);    }

Добавим видимость сервиса ServiceABC в файл _ViewImports.cshtml:
@using ConfigurationTemplate_2.Services;

Изменим Index.cshtml для отображения параметра Parameter1 на странице.
@model ServiceABC@{    ViewData["Title"] = "Home Page";}<div class="text-center">    <h1>Десериализация конфигурации в ASP.NET Core</h1>    <h4>Использование IConfiguration как Singleton</h4></div><div>    <p>        Сервис ServiceABC, отображение Заголовка        из параметра Parameter1 = @Model.Title    </p></div>


Запустим приложение:


Сервис ServiceABC добавленный в контейнер с помощью AddScoped означает, что экземпляр класса будет создаваться при каждом запросе страницы. В результате экземпляр класса ServiceABC будет создаваться при каждом http запросе вместе с перезагрузкой конфигурации IConfiguration, и новые изменения в appsettings.json будут применяться.
Таким образом, если во время работы приложения изменить параметр Parameter1 на NEW!!! Parameter1 ABC, то при следующем обращение к начальной странице отобразится новое значение параметра.

Обновим страницу после изменения файла appsettings.json:


Итог


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

Десериализация конфигурации с валидацией (вариант IOptions)


GitHub: ConfigurationTemplate_3
Почитать про Options по ссылке.
В этом варианте необходимость использования ServiceABC отпадает. Вместо него используется класс AppSettings, который содержит параметры из конфигурационного файла и объект ClientConfig. Объект ClientConfig после изменения конфигурации требуется инициализировать, т.к. в контроллерах используется готовый объект. ClientConfig это некий класс, взаимодействующий с внешними системами, код которого нельзя изменять. Если выполнить только десериализацию данных класса AppSettings, то ClientConfig будет в состояние null. Поэтому необходимо подписаться на событие чтения конфигурации, и в обработчике инициализировать объект ClientConfig.
Для передачи конфигурации не в виде пар ключ-значение, а как объекты определенных классов, будем использовать интерфейс IOptions. Дополнительно IOptions в отличие от ConfigurationManager позволяет десерилизовать отдельные секции. Для создания объекта ClientConfig потребуется использовать IPostConfigureOptions, который выполняется после обработки всех конфигурации. IPostConfigureOptions будет выполняться каждый раз после чтения конфигурации, самым последним.
Создадим ClientConfig.cs:
public class ClientConfig{  private string _parameter1;  private string _parameter2;  public string Value => _parameter1 + " " + _parameter2;  public ClientConfig(ClientConfigOptions configOptions)    {      _parameter1 = configOptions.Parameter1;      _parameter2 = configOptions.Parameter2;    }}

В качестве конструктора будет принимать параметры в виде объекта ClientConfigOptions:
public class ClientConfigOptions{  public string Parameter1;  public string Parameter2;} 

Создадим класс настроек AppSettings, и определим в нем метод ClientConfigBuild(), который создаст объект ClientConfig.
Файл AppSettings.cs:
public class AppSettings{          public string Parameter1 { get; set; }  public string Parameter2 { get; set; }          public ClientConfig clientConfig;  public void ClientConfigBuild()    {      clientConfig = new ClientConfig(new ClientConfigOptions()        {          Parameter1 = this.Parameter1,          Parameter2 = this.Parameter2        }        );      }}

Создадим обработчик конфигурации, который будет отрабатываться последним. Для этого он должен быть унаследован от IPostConfigureOptions. Вызываемый последним метод PostConfigure выполнит ClientConfigBuild(), который как раз и создаст ClientConfig.
Файл ConfigureAppSettingsOptions.cs:
public class ConfigureAppSettingsOptions: IPostConfigureOptions<AppSettings>{  public ConfigureAppSettingsOptions()    { }  public void PostConfigure(string name, AppSettings options)    {                  options.ClientConfigBuild();    }}

Теперь осталось внести изменения только в Startup.cs, изменения коснутся только функции ConfigureServices(IServiceCollection services).
Сначала прочитаем секцию AppSettings в appsettings.json
// configure strongly typed settings objectsvar appSettingsSection = Configuration.GetSection("AppSettings");services.Configure<AppSettings>(appSettingsSection);

Далее, для каждого запроса будет создаваться копия AppSettings для возможности вызова постобработки:
services.AddScoped(sp => sp.GetService<IOptionsSnapshot<AppSettings>>().Value);

Добавим в качестве сервиса, постобработку класса AppSettings:
services.AddSingleton<IPostConfigureOptions<AppSettings>, ConfigureAppSettingsOptions>();

Добавленный код в Startup.cs
public void ConfigureServices(IServiceCollection services){  // configure strongly typed settings objects  var appSettingsSection = Configuration.GetSection("AppSettings");  services.Configure<AppSettings>(appSettingsSection);  services.AddScoped(sp => sp.GetService<IOptionsSnapshot<AppSettings>>().Value);                                      services.AddSingleton<IPostConfigureOptions<AppSettings>, ConfigureAppSettingsOptions>();              //next  services.AddControllersWithViews();}

Для получения доступа к конфигурации, из контроллера достаточно будет просто внедрить AppSettings.
Файл HomeController.cs:
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly AppSettings _appSettings;  public HomeController(ILogger<HomeController> logger, AppSettings appSettings)    {      _logger = logger;      _appSettings = appSettings;    }

Изменим Index.cshtml для вывода параметра Value объекта СlientConfig
@model AppSettings@{    ViewData["Title"] = "Home Page";}<div class="text-center">    <h1>Десериализация конфигурации в ASP.NET Core</h1>    <h4>Десериализация конфигурации с валидацией (вариант IOptions)</h4></div><div>    <p>        Класс ClientConfig, отображение Заголовка         = @Model.clientConfig.Value    </p></div>

Запустим приложение:


Если во время работы приложения изменить параметр Parameter1 на NEW!!! Parameter1 ABC и Parameter2 на NEW!!! Parameter2 ABC, то при следующем обращении к начальной странице отобразится новое свойства Value:



Итог


Данный подход позволяет десериализовать все значения конфигурации без ручного перебирания параметров. Каждый запрос http работает со своим экземпляром AppSettings и СlientConfig, что исключает ситуацию возникновения коллизий. IPostConfigureOptions гарантирует выполнение в последнюю очередь, когда все параметры будут перечитаны. Недостатком решения является постоянное создание экземпляра СlientConfig для каждого запроса, что является непрактичным т.к. по сути СlientConfig должен пересоздаваться только после изменения конфигурации.

Десериализация конфигурации с валидацией (без использования IOptions)


GitHub: ConfigurationTemplate_4
Использование подхода с использованием IPostConfigureOptions приводит к созданию объекта ClientConfig каждый раз при получении запроса от клиента. Это недостаточно рационально т.к. каждый запрос работает с начальным состоянием ClientConfig, которое меняется только при изменение конфигурационного файла appsettings.json. Для этого откажемся от IPostConfigureOptions и создадим обработчик конфигурации который будет вызваться только при изменении appsettings.json, в результате ClientConfig будет создаваться только один раз, и далее на каждый запрос будет отдаваться уже созданный экземпляр ClientConfig.
Создадим класс SingletonAppSettings конфигурации(Singleton) с которого будет создаваться экземпляр настроек для каждого запроса.
Файл SingletonAppSettings.cs:
public class SingletonAppSettings{  public AppSettings appSettings;    private static readonly Lazy<SingletonAppSettings> lazy = new Lazy<SingletonAppSettings>(() => new SingletonAppSettings());  private SingletonAppSettings()    { }  public static SingletonAppSettings Instance => lazy.Value;}

Вернемся в класс Startup и добавим ссылку на интерфейс IServiceCollection. Он будет использоваться в методе обработки конфигурации
public IServiceCollection Services { get; set; }

Изменим ConfigureServices(IServiceCollection services) и передадим ссылку на IServiceCollection:
Файл Startup.cs:
public void ConfigureServices(IServiceCollection services){  Services = services;  //Считаем секцию AppSettings из конфигурации  var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();  appSettings.ClientConfigBuild();

Создадим Singleton конфигурации, и добавим его в коллекцию сервисов:
SingletonAppSettings singletonAppSettings = SingletonAppSettings.Instance;singletonAppSettings.appSettings = appSettings;services.AddSingleton(singletonAppSettings);     

Добавим объект AppSettings как Scoped, при каждом запросе будет создаваться копия от Singleton:
services.AddScoped(sp => sp.GetService<SingletonAppSettings>().appSettings);

Полностью ConfigureServices(IServiceCollection services):
public void ConfigureServices(IServiceCollection services){  Services = services;  //Считаем секцию AppSettings из конфигурации  var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();  appSettings.ClientConfigBuild();  SingletonAppSettings singletonAppSettings = SingletonAppSettings.Instance;  singletonAppSettings.appSettings = appSettings;  services.AddSingleton(singletonAppSettings);               services.AddScoped(sp => sp.GetService<SingletonAppSettings>().appSettings);  //next  services.AddControllersWithViews();}

Теперь добавить обработчик для конфигурации в Configure(IApplicationBuilder app, IWebHostEnvironment env). Для отслеживания изменения в файле appsettings.json используется токен. OnChange вызываемая функция при изменении файла. Обработчик конфигурации onChange():
ChangeToken.OnChange(() => Configuration.GetReloadToken(), onChange);

Вначале читаем файл appsettings.json и десериализуем класс AppSettings. Затем из коллекции сервисов получаем ссылку на Singleton, который хранит объект AppSettings, и заменяем его новым.
private void onChange(){                          var newAppSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();  newAppSettings.ClientConfigBuild();  var serviceAppSettings = Services.BuildServiceProvider().GetService<SingletonAppSettings>();  serviceAppSettings.appSettings = newAppSettings;  Console.WriteLine($"AppSettings has been changed! {DateTime.Now}");}

В контроллер HomeController внедрим ссылку на AppSettings, как в предыдущем варианте (ConfigurationTemplate_3)
Файл HomeController.cs:
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly AppSettings _appSettings;  public HomeController(ILogger<HomeController> logger, AppSettings appSettings)    {      _logger = logger;      _appSettings = appSettings;    }

Изменим Index.cshtml для вывода параметра Value объекта СlientConfig:
@model AppSettings@{    ViewData["Title"] = "Home Page";}<div class="text-center">    <h1>Десериализация конфигурации в ASP.NET Core</h1>    <h4>Десериализация конфигурации с валидацией (без использования IOptions)</h4></div><div>    <p>        Класс ClientConfig, отображение Заголовка        = @Model.clientConfig.Value    </p></div>


Запустим приложение:


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


И новые значения:


Итог


Данный вариант лучше использования IPostConfigureOptions т.к. позволяет строить объект только после изменения файла конфигурации, а не при каждом запросе. В результате достигается уменьшение времени на ответ сервера. После срабатывания токена, состояние токена сбрасывается.

Добавление значений по умолчанию и валидация конфигурации


GitHub: ConfigurationTemplate_5
В предыдущих примерах при отсутствии файла appsettings.json приложение выбросит исключение, поэтому сделаем файл конфигурации опциональным и добавим настройки по умолчанию. При публикации приложения проекта, созданного из шаблона в Visula Studio, файл appsettings.json будет располагаться в одной и той же папке вместе со всеми бинарными файлами, что неудобно при развертывание в Docker. Файл appsettings.json перенесем в папку config/:
.AddJsonFile("config/appsettings.json")

Для возможности запуска приложения без appsettings.json изменим параметр optional на true, который в данном случае означает, что наличие appsettings.json является необязательным.
Файл Startup.cs:
public Startup(IConfiguration configuration){  var builder = new ConfigurationBuilder()     .AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: true);  configuration = builder.Build();  Configuration = configuration;}

Добавим в public void ConfigureServices(IServiceCollection services) к строке десериализации конфигурации случай обработки отсутствия файла appsettings.json:
 var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();

Добавим валидацию конфигурации, на основе интерфейса IValidatableObject. При отсутствующих параметрах конфигурации, будет применяться значение по умолчанию. Наследуем класс AppSettings от IValidatableObject и реализуем метод:
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)

Файл AppSettings.cs:
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext){  List<ValidationResult> errors = new List<ValidationResult>();  if (string.IsNullOrWhiteSpace(this.Parameter1))    {      errors.Add(new ValidationResult("Не указан параметр Parameter1. Задано " +        "значение по умолчанию DefaultParameter1 ABC"));      this.Parameter1 = "DefaultParameter1 ABC";    }    if (string.IsNullOrWhiteSpace(this.Parameter2))    {      errors.Add(new ValidationResult("Не указан параметр Parameter2. Задано " +        "значение по умолчанию DefaultParameter2 ABC"));      this.Parameter2 = "DefaultParameter2 ABC";    }    return errors;}

Добавим метод вызова проверки конфигурации для вызова из класса Startup
Файл Startup.cs:
private void ValidateAppSettings(AppSettings appSettings){  var resultsValidation = new List<ValidationResult>();  var context = new ValidationContext(appSettings);  if (!Validator.TryValidateObject(appSettings, context, resultsValidation, true))    {      resultsValidation.ForEach(        error => Console.WriteLine($"Проверка конфигурации: {error.ErrorMessage}"));      }    }

Добавим вызов метода валидации конфигурации в ConfigureServices(IServiceCollection services). Если файла appsettings.json отсутствует, то требуется инициализировать объект AppSettings со значениями по умолчанию.
Файл Startup.cs:
var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();

Проверка параметров. В случае использования значения по умолчанию в консоль будет выведено сообщение с указанием параметра.
 //Validate            this.ValidateAppSettings(appSettings);            appSettings.ClientConfigBuild();

Изменим проверку конфигурации в onChange()
private void onChange(){                          var newAppSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();  //Validate              this.ValidateAppSettings(newAppSettings);              newAppSettings.ClientConfigBuild();  var serviceAppSettings = Services.BuildServiceProvider().GetService<SingletonAppSettings>();  serviceAppSettings.appSettings = newAppSettings;  Console.WriteLine($"AppSettings has been changed! {DateTime.Now}");}

Если из файла appsettings.json удалить ключ Parameter1, то после сохранения файла в окне консольного приложения появится сообщение об отсутствие параметра:


Итог


Изменение пути для расположения конфигураций в папке config является хорошим решением т.к. позволяет не смешивать все файлы в одну кучу. Папка config определяется только для хранения конфигурационных файлов. Упростили задачу развертывания и конфигурирования приложения для администраторов, благодаря валидации конфигурации. Если добавить вывод ошибок конфигурации в лог, то администратор в случае указания неправильных параметров, получит точную информацию о проблеме, а не как в последнее время программисты на любое исключение стали писать: Что -то пошло не так.
Идеального варианта работы с конфигурацией не существует, все зависит от поставленной задачи, у каждого варианта есть свои плюсы и минусы.

Все шаблоны конфигураций доступны по ссылке.

Литература:
  1. Корректный ASP.NET Core
  2. METANIT Конфигурация. Основы конфигурации
  3. Singleton Design Pattern C# .net core
  4. Reloading configuration in .NET core
  5. Reloading strongly typed Options on file changes in ASP.NET Core RC2
  6. Конфигурация ASP.NET Core приложения через IOptions
  7. METANIT Передача конфигурации через IOptions
  8. Конфигурация ASP.NET Core приложения через IOptions
  9. METANIT Самовалидация модели
Подробнее..
Категории: C , Net , Net core , Configuration , Asp , Asp.net

Конвертируем doc в docx и xml на C

23.11.2020 10:05:21 | Автор: admin

Продолжаю свой цикл статей, посвященный конвертации различных текстовых файлов с помощью решений, реализованных на языке C#.


С момента моей последней публикации Конвертация xls в xlsx и xml на C# прошло более полугода, за которые я успел сменить как работодателя, так и пересмотреть свои взгляды на некоторые аспекты коммерческой разработки. Сейчас, работая в международной компании с совершенно иным подходом к разработке ПО (ревью кода, юнит-тестирование, команда автотестеров, строгое соблюдение СМК, заботливый менеджер, очаровательная HR и прочие корпоративные плюшки), я начинаю понимать, почему некоторые из комментаторов интересовались целесообразностью предлагаемых мной велокостылей, когда на рынке есть очень достойные готовые решения, например, от e-iceblue. Но давайте не забывать, что ситуации бывают разные, компании тем более, и если потребность в решении какой-то задачи с использованием определенного инструментария возникла у одного человека, то со значительной долей вероятности она возникнет и у другого.



Итак, дано:


  1. Неопределенное множество файлов в формате .doc, которые нужно конвертировать в xml (например, для парсинга и организации автоматизированной навигации внутри текста), желательно с сохранением форматирования.
  2. На сервере памяти чуть больше, чем у рыбки, а на процессоре уже можно жарить яичницу, да и у компании нет лишней лицензии на Word, поэтому конвертация должна происходить без запуска каких-либо офисных приложений.
  3. Сервис должен быть написан на языке C# и в последующем интегрирован в код другого продукта.
  4. На решение задачи два дня и две ночи, которые истекли вчера.

Поехали!


  • Во-первых, нужно сразу уяснить, что старые офисные форматы файлов, такие как .doc и .xls, являются бинарными, и достать что-нибудь человекочитаемое из них без использования текстовых редакторов/процессоров не получится. Прочитать об этом можно в официальной документации. Если есть желание поковыряться поглубже, посчитать нолики с единичками и узнать, что они означают, то лучше сразу перейти сюда.
  • Во-вторых, несмотря на наличие бесплатных решений для работы с .doc, большинство из них написаны на Python, Ruby и чем угодно еще, но не C#.
  • В-третьих, найденное мной решение, а именно библиотека b2xtranslator, является единственным доступным бесплатным инструментом такого рода, еще и написана при поддержке Microsoft, если верить вот этому источнику. Если вдруг вы встречали какие-нибудь аналоги данной библиотеки, пожалуйста, напишите об этом в комментариях. Даже это душеспасительное решение не превратит .doc в .xml, однако поможет нам превратить его в .docx, с которым мы уже умеем работать.

Довольно слов давайте к делу


Установка b2xtranslator


Для работы нам понадобиться библиотека b2xtranslator. Ее можно подключить через менеджера пакетов NuGet.

Однако я настоятельно рекомендую скачать ее из официального git-репозитория по следующим причинам:


  • a) Библиотека представляет собой комбайн, работающий с различными бинарными офисными документами (.doc, .xls, .ppt), что может быть избыточным
  • b) Проект достаточно долго не обновляется и вам, возможно, придется доработать его напильником
  • c) Задача, с которой я столкнулся, как раз потребовала внесения некоторых изменений в работу библиотеки, а также изучения ее алгоритмов и используемых структур для успешной интеграции в свое решение
    Для дальнейшей работы нам понадобиться подключить в свое решение два проекта из библиотеки: b2xtranslator\Common\b2xtranslator.csproj и b2xtranslator\Doc\b2xtranslator.doc.csproj

Конвертация .doc в .docx


Конвертация документов строится по следующему алгоритму:


  1. Инициализация дескриптора для конвертируемого файла.
    Для этого необходимо создать экземпляр класса StructuredStorageReader, конструктор которого в качестве аргумента может принимать или путь до файла, или последовательность байтов (Stream), что делает его крайне удобным при работе с файлами, загружаемыми по сети. Также обращаю внимание, что так как библиотека b2xtranslator является комбайном для конвертации бинарных офисных форматов в современный OpenXML, то независимо от того, какой формат мы хотим конвертировать (.ppt, .xls или .doc) инициализация дескриптора всегда будет происходить с помощью указанного класса (StructuredStorageReader).
    StructuredStorageReader reader = new StructuredStorageReader(docPath);
    
  2. Парсинг бинарного .doc файла с помощью объекта класса WordDocument, конструктор которого в качестве аргумента принимает объект типа StructuredStorageReader.
    WordDocument doc = new WordDocument(reader);
    
  3. Создание объекта, который будет хранить данные для файла в формате .docx.
    Для этого используется статический метод cs public static WordprocessingDocument Create(string fileName, OpenXmlPackage.DocumentType type) класса WordprocessingDocument. В первом аргументе указываем имя нового файла (вместе с путем), а вот во втором мы должны выбрать тип файла, который должен получиться на выходе:
    a. Document (обычный документ с расширением .docx);
    b. MacroEnabledDocument (файл, содержащий макросы, с расширением .docm);
    c. Template (файл шаблонов word с расширением .dotx);
    d. MacroEnabledTemplate (файл с шаблоном word, содержащий макросы. Имеет расширение .dotm).
    WordprocessingDocument docx = WordprocessingDocument.Create(docxPath, DocumentType.Document);
    
  4. Конвертация данных из бинарного формата в формат OpenXML и их запись в объект типа WordprocessingDocument.
    За выполнение указанной процедуры отвечает статический метод
    public static void Convert(WordDocument doc, WordprocessingDocument docx)
    

    класса Converter, который заодно и записывает получившийся результат в файл.

    Converter.Convert(doc, docx);
    

    В результате у вас должен получиться вот такой код:

    using b2xtranslator.StructuredStorage.Reader;using b2xtranslator.DocFileFormat;using b2xtranslator.OpenXmlLib.WordprocessingML;using b2xtranslator.WordprocessingMLMapping;using static b2xtranslator.OpenXmlLib.OpenXmlPackage;namespace ConverterToXml.Converters{    public class DocToDocx    {        public void ConvertToDocx(string docPath, string docxPath)        {            StructuredStorageReader reader = new StructuredStorageReader(docPath);            WordDocument doc = new WordDocument(reader);            WordprocessingDocument docx = WordprocessingDocument.Create(docxPath, DocumentType.Document);            Converter.Convert(doc, docx);        }    }}
    

    Внимание!
    Если вы используете платформу .Net Core 3 и выше в своем решении, обратите внимание на целевые среды для подключенных проектов b2xtranslator. Так как библиотека была написана довольно давно и не обновляется с 2018 года, по умолчанию она собирается под .Net Core 2.
    Чтобы сменить целевую среду, щелкните правой кнопкой мыши по проекту, выберите пункт Свойства и поменяйте целевую рабочую среду. В противном случае вы можете столкнуться с проблемой невозможности конвертации файлов .doc, содержащих в себе таблицы.
    Я не стал разбираться, почему так происходит, но энтузиастам могу подсказать, что причину стоит искать в 40 строчке файла ~\b2xtranslator\Doc\WordprocessingMLMapping\MainDocumentMapping.cs в момент обработки таблицы.
    Кроме того, рекомендую собирать все проекты и само решение под 64-битную платформу во избежание всяких непонятных ошибок.



    Сохранение результата в поток байтов


    Так как моей целью при использовании данного решения была конвертация .doc в .xml, а не в .docx, предлагаю вовсе не сохранять промежуточный OpenXML файл, а записать его в виде потока байтов. К сожалению, b2xtranslator не предоставляет нам подходящих методов, но это довольно легко исправить:
    В абстрактном классе OpenXmlPackage (см. ~\b2xtranslator\Common\OpenXmlLib\OpenXmlPackage.cs) давайте создадим виртуальный метод:


    public virtual byte[] CloseWithoutSavingFile(){    var writer = new OpenXmlWriter();    MemoryStream stream = new MemoryStream();    writer.Open(stream);    this.WritePackage(writer);    writer.Close();    byte[] docxStreamArray = stream.ToArray();    return docxStreamArray;}
    

    По большому счету, данный метод будет заменять собой метод Close(). Вот его исходный код:


    public virtual void Close(){     // serialize the package on closing    var writer = new OpenXmlWriter();    writer.Open(this.FileName);    this.WritePackage(writer);    writer.Close();}
    

    Скажем спасибо разработчикам библиотеки за то, что не забыли перегрузить метод Open(), который может принимать или имя файла, или поток байтов. Однако, библиотечный метод Close(), который как раз и отвечает за запись результата в файл, вызывается в методе Dispose() в классе OpenXmlPackage. Чтобы ничего лишнего не поломать и не заморачиваться с архитектурой фабрик (тем более в чужом проекте), я предлагаю просто закомментировать код внутри метода Dispose() и вызвать метод CloseWithoutSavingFile(), но уже внутри нашего метода после вызова Converter.Convert(doc, docx).
    Для сохранения результата конвертации вызываем вместо docx.Close() метод docx.CloseWithoutSavingFile():


    public MemoryStream ConvertToDocxMemoryStream(Stream stream){    StructuredStorageReader reader = new StructuredStorageReader(stream);    WordDocument doc = new WordDocument(reader);    var docx = WordprocessingDocument.Create("docx", DocumentType.Document);    Converter.Convert(doc, docx);    return new MemoryStream(docx.CloseWithoutSavingFile());}
    

    Теперь библиотека b2xtranslator будет возвращать сконвертированный из формата .doc в .docx файл в виде потока байтов. Даже если у вас нет цели получить на выходе .xml, такой метод может оказаться более подходящим для дальнейшей работы с файлами, тем более что стрим всегда можно сохранить в виде файла там, где вам надо.
    Для тех, кому все-таки очень хочется получить на выходе .xml документ, еще и с сохраненной структурой, предлагаю дойти до кухни, сварить кофе покрепче, добавить в него рюмку коньяка и приготовиться к приключению на 20 минут.


    Конвертация .doc в .xml



    Теперь, когда, казалось бы, можно воспользоваться классом-конвертором DocxToXml, работа которого была описана вот в этой статье, нас поджидает сюрприз, связанный с особенностями работы b2xtranslator.
    Давайте посмотрим на результат работы библиотеки повнимательнее и сравним с оригинальным .docx файлом, из которого был экспортирован .doc файл для конвертации. Для этого достаточно изменить расширение сравниваемых файлов с .docx на .zip. Вот отличия, которые мы увидим, заглянув внутрь архивов:


    1. В результате конвертации в новом .docx файле (справа) отсутствуют папки customXml и docProps.
    2. Внутри папки word, мы также найдем определенные отличия, перечислять которые я, конечно же, не буду:
    3. Естественно, что и метаданные, по которым осуществляется навигация внутри документа, также отличаются. Например, на представленном скрине и далее оригинальный .docx слева, сгенерированный b2xtranslator cправа.

      Налицо явное отличие в атрибутах тега w:document, но этим отличия не заканчиваются. Всю "мощь" библиотеки мы ощутим, когда захотим обработать списки и при этом:
      a. Сохранить их нумерацию
      b. Не потерять структуру вложенности
      c. Отделить один список от другого

    Давайте сравним файлы document.xml для вот этого списка:


    1.1 Первый.Первый1.2 Первый.Второй1.2.1   Первый.Второй.Первый1.2.2   Первый.Второй.ВторойКакая-то строчка 1.2.3   Первый.Второй.Третий2.  Второй2.1 Второй.Первый
    

    Вот так будет выглядеть .xml для первого элемента списка.


    -Во-первых, мы видим, что сама структура документов несколько отличается (например, точка внутри строк рассматривается как отдельный элемент, что, как оказалось, совсем не страшно).
    -Во-вторых, у тегов остался только один атрибут (w:rsidR), а вот w:rsidR, w14:textId, w:rsidRDefault, w:paraId и w:rsidP пропали. Все эти особенности приводят к тому, что наш класс-конвертер DocxToXml(про него подробно можно почитать здесь) подавится и поднимет лапки вверх с ошибкой NullReferenceException, что указывает на отсутствие индексирования параграфов внутри документа.

    Вместе с тем, если мы попытаемся такой файл отрыть в Word, то увидим, что все хорошо отображается, а таблицы и списки покоятся на своих местах! Магия!
    В общем, когда в поисках решения я потратил N часов на чтение документации, мои красные от дебагера глаза омылись горькими слезами, а один лишь запах кофе стремился показать коллегам мой дневной рацион, решение было найдено!
    Исходя из документации к формату doc и алгоритмов работы b2xtranslator, можно сделать вывод, что исторически в бинарных офисных текстовых документах отсутствовала индексация по параграфам*. Возникает задача расставить необходимые теги в нужных местах.
    За индекс параграфа отвечает атрибут тега paraId, о чем прямо написано здесь. Данный атрибут относится к пространству имен w14, о чем можно догадаться при изучении document.xml из архива .docx. В принципе, на скринах выше вы это тоже видите. Объявление пространства имен в .xml выглядит так:


    xmlns:wp14="http://personeltest.ru/away/schemas.microsoft.com/office/word/2010/wordprocessingDrawing"
    

    Теперь давайте заставим b2xtranslator добавлять это пространство имен и идентификатор каждому параграфу. Для этого в файле ~\b2xtranslator\Common\OpenXmlLib\ContentTypes.cs после 113 строки добавим вот эту строчку:


    public const string WordprocessingML2010 = "http://schemas.microsoft.com/office/word/2010/wordml";
    

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

    Далее наша задача заставить библиотеку вставлять в начало файла ссылку на данное пространство имен. Для этого в файле ~\b2xtranslator\Doc\WordprocessingMLMapping\MainDocumentMapping.cs в 24 строке вставим код:


    this._writer.WriteAttributeString("xmlns", "w14", null, OpenXmlNamespaces.WordprocessingML2010);
    

    Разработчики библиотеки также позаботились о документации:


    Теперь дело за малым заставить b2xtranslator индексировать параграфы. В качестве индексов предлагаю использовать рандомно сгенерированные GUID может быть, это несколько тяжеловато, но зато надежно!

    Переходим в файл ~\b2xtranslator\Doc\WordprocessingMLMapping\DocumentMapping.cs и в 504 и 505 строки вставляем вот этот код:


    this._writer.WriteAttributeString("w14", "paraId", OpenXmlNamespaces.WordprocessingML2010, Guid.NewGuid().ToString());            this._writer.WriteAttributeString("w14", "textId", OpenXmlNamespaces.WordprocessingML2010, "77777777");
    

    Что касается второй строчки, в которой мы добавляем каждому тегу параграфа атрибут w14:textId = "77777777", то тут можно лишь сказать, что без этого атрибута ничего работать не будет. Для пытливых умов вот ссылка на документацию.
    Если серьезно, то, как я понимаю, атрибут используется, когда текст разделен на разные блоки, внутри которых происходит индексация тегов, которые могут иметь одинаковый Id внутри одного документа. Видимо, для этих случаев используется дополнительная индексация текстовых блоков. Однако, так как мы используем GUID, который в несколько раз больше индексов, используемых в вордовских документах по умолчанию, то генерацией отдельных индексов для текстовых блоков можно и пренебречь.


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


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


    Наконец, бонус для тех, кто хочет разобраться, что значат все эти бесконечные теги и их атрибуты в документах .docx и как они мапаются на бинарный .doc: советую заглянуть в файл ~\b2xtranslator\Doc\DocFileFormat\CharacterProperties.cs, а также посмотреть спецификацию для docx и doc.

Подробнее..

Простое и удобное журналирование ошибок для сайтов на .NET Core

29.12.2020 10:06:55 | Автор: admin

Возможно, многим знакома библиотека ELMAH (Error Logging Modules and Handlers), которая позволяет организовать простое журналирование ошибок для любого сайта, созданного с помощью .NET Framework.



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


Но это же opensource проект! Несколько выходных в работе над форком, и вот готова первая версия ELMAH работающая под .NET Core.


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


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


  • Тип и информация об исключении, стек вызова
  • Информация об HTTP запросе: данные шапки запроса (header), параметры запроса, cookies, данные о подключении пользователя
  • Информация о текущем пользователе
  • Информация о текущей сессии на сервере
  • Переменные среды сервера

В новой версии я добавил регистрацию всех сообщений, созданных через Microsoft.Extensions.Logging (ILogger) в привязке к контексту HTTP запроса.
Вся эта информация может быть сохранена:


  • в памяти сервера
  • в XML файлах в папке на сервере
  • в СУБД, сейчас поддерживаются MSSQL, MySQL, PostgreSQL

Подключение данной библиотеки максимально простое:


  1. Добавить в проект nuget-пакет elmahcore.
  2. Добавить следующие строчки в Startup.cs:

services.AddElmah(); // в метод ConfigureServices app.UseElmah(); // в начале метода Configure

Для доступа к журналу ошибок библиотека предоставляет программный и пользовательский интерфейс.
Интерфейс пользователя, по умолчанию, доступен по пути ~/elmah.
В новой версии я существенно переработал UI, реализовав его на VUE.js



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


services.AddElmah(options =>{   options.SourcePaths = new []   {      @"D:\tmp\ElmahCore.DemoCore3",      @"D:\tmp\ElmahCore.Mvc",      @"D:\tmp\ElmahCore"   };});

На закладке Log отображается журнал сообщений Microsoft.Extensions.Logging в контексте HTTP запроса в котором была зарегистрирована ошибка.



Понятно, что такая ценная информация не для публичных глаз!
Чтобы защитить доступ к журналу, необходимо добавить проверку прав доступа:


services.AddElmah(options =>{        options.OnPermissionCheck = context => context.User.Identity.IsAuthenticated;});

При этом вызов UseElmah, должен быть позже UseAuthentication и UseAuthorization


app.UseAuthentication();app.UseAuthorization();app.UseElmah();

Можно организовать фильтрацию регистрируемых ошибок с помощь фильтров, реализованных в коде (реализующих интерфейс IErrorFilter) или в xml-файле конфигурации (https://elmah.github.io/a/error-filtering/examples/).


services.AddElmah<XmlFileErrorLog>(options =>{    options.FiltersConfig = "elmah.xml";    options.Filters.Add(new MyFilter());})

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


services.AddElmah<XmlFileErrorLog>(options =>{    options.Notifiers.Add(new ErrorMailNotifier("Email",emailOptions));});

Надеюсь, что эта бесплатная библиотека будет полезна в ваших проектах.
Подробнее с библиотекой можно познакомиться здесь: https://github.com/ElmahCore/ElmahCore

Подробнее..
Категории: C , Open source , Net , Net core , Asp , Asp.net , Error handling , Error_reporting

Как установить файл конфигурации в .Net Core Console app для нескольких сред разработки при запуске Docker-контейнера

19.01.2021 00:21:31 | Автор: admin
Наша команда разрабатывала сервис обработки сообщений из Kafka. Он представлял собой консольное приложение .Net Core, которое подписывалось на топики, и при появлении сообщения в каждом из них выполняло определённый алгоритм обработки. На первых итерациях разработки нашего сервиса развёртывание было устроено довольно просто: мы делали publish приложения, переносили готовые файлы билда на сервер, создавали docker-образ и запускали сервис в контейнере. Мы жили так, пока к нам не пришло нагрузочное тестирование и развернулось в соседнем контуре. Файл конфигурации appsettings.json в этих контурах, естественно, отличался и у нас появился ещё один шаг в нашем деплое это исправление файла конфигурации ручками. На этом этапе вмешался человеческий фактор и иногда мы забывали править файл, что приводило к ошибкам и потери времени. Когда нам это сильно надоело (очень быстро), мы решили призвать DevOps на помощь. Но всё же это требовало времени, а править конфиг руками сил больше не было. Тогда я придумала и реализовала довольно быстрое решение, про которое и хочу рассказать в этой статье.

Вот наши начальные условия:

  1. Наш сервис это Console Application и в отличии от ASP.NET Core Web Application у нас не было решения из коробки.
  2. Запуск приложения происходит из docker-контейнера.

Что ж, давайте разберемся, как использовать мульти конфигурацию в консольном приложении. Для начала создадим файлы конфигураций для наших окружений Dev и Testing:


В консольных приложениях нет вложенности по дефолту. Поэтому открываем файл проекта .csproj и добавляем:

<ItemGroup><Content Include="appsettings.json"><CopyToOutputDirectory>Always</CopyToOutputDirectory></Content><Content Include="appsettings.Dev.json;appsettings.Testing.json;"><DependentUpon>appsettings.json</DependentUpon><CopyToOutputDirectory>Always</CopyToOutputDirectory></Content></ItemGroup>


В файле appsettings.json появились вложенные файлы с названием среды разработки:



В файлы appsettings.Dev.json и appsettings.Testing.json добавляем те части конфига, которые меняются в зависимости от среды. Изменим название топиков Kafka в контуре нагрузочного тестирования, для этого добавим нужные параметры в appsettings.Testing.json:

{"Kafka":   {"EventMainTopicTitle": "Test_EventMain","EventDelayTopicTitle": "Test_EventDelay","EventRejectTopicTitle": "Test_EventReject"}}


Осталось только выбрать нужный файл appsettings.json во время старта сервиса. Для этого внесем изменения в класс Program:

/// Конфигурация сервисаprivate static IServiceProvider ConfigureServices(){    // Установка имени переменной средыconst string environmentVariableName = "ASPNETCORE_ENVIRONMENT";    // Получение значения переменной средыvar environmentName =Environment.GetEnvironmentVariable(environmentVariableName);var services = new ServiceCollection();_configuration = new ConfigurationBuilder().SetBasePath(Directory.GetParent(AppContext.BaseDirectory).FullName).AddJsonFile("appsettings.json")        // Добавление json-файла для среды environmentName.AddJsonFile($"appsettings.{environmentName}.json").AddEnvironmentVariables().Build();services.AddSingleton(_configuration);services.AddSingleton<KafkaHandler>();    return services.BuildServiceProvider();}


Теперь всё готово к запуску сервиса в docker-контейнере.

Осталось указать переменные окружения для контейнера. Есть несколько способов сделать это:
  • командная строка
  • текстовый файл
  • docker compose

Я остановилась на указании переменных в командной строке. Вот пример скрипта для создания образа и запуска контейнера:

# Build image# docker build . -t consoleapp# Run container on Dev# docker run -d <i>--env ASPNETCORE_ENVIRONMENT=Dev</i> --name app consoleapp


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

Ссылка на GitHub с проектом.

Спасибо за внимание и приятного кодинга!
Подробнее..
Категории: C , Devops , Net , Net core , Docker , Console application , Config files

Реализация Minecraft Query протокола в .Net Core

23.02.2021 20:21:55 | Автор: admin

Minecraft Server Query это простой протокол, позволяющий получить актуальную информацию о состоянии сервера путём отправки пары-тройки незамысловатых UDP-пакетов.

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

Так было принято решение написать свою реализацию.

Скажи мне, кто ты...

Для начала, посмотрим, что из себя представляет сам протокол Minecraft Query. Согласно вики, мы имеем в распоряжении 3 вида пакетов запросов и, соотвественно, 3 вида пакетов ответа:

  • Handshake

  • BasicStatus

  • FullStatus

Первый тип пакета используется для получения ChallengeToken, необходимого для формирования других двух пакетов. Привязывается он к IP-адресу отправителя на 30 секунд. Смысловая нагрузка оставшихся двух ясна из названий.

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

Ответ на запрос BasicStatusОтвет на запрос BasicStatus

А вот так FullStatus

Ответ на запрос FullStatusОтвет на запрос FullStatus

Все данные, помимо тех, что хранятся в short, представлены в big-endian. А для поля SessionId, которое постоянно в рамках одного клиент-сервер соединения, должно выполняться условие SessionId & 0x0F0F0F0F == SessionId.

В общем виде запрос выглядит так

Запрос в общем видеЗапрос в общем виде

Более подробно об этом об этом можно почитать на вики.

И я скажу тебе, как тебя распарсить

Для начала, определимся, что мы хотим получить на выходе. Готовая библиотека должна предоставлять API для отправки любого из 3 видов пакетов и получения результата в распаршеном виде.

При этом, я хочу больше свободы в плане поддержания жизнеспособности сокетов и обновления ChallengeToken. Если я буду запрашивать состояние сервера каждые 3 секунды, то я не хочу, чтобы вместо одного пакета запроса отправлялось два: хэндшейк и состояние. И наоборот, если я опрашиваю сервер раз в час, зачем мне слать запросы каждые 30 секунд? Поэтому работа с библиотекой будет происходить в "ручном" режиме.

Итак, определившись, можем уже представить, как будет выглядеть архитектура классов. Я вижу работу примерно таким образом

public static async Task<ServerState> DoSomething(IPAddress host, int port) {var mcQuery = new McQuery(host, port);  mcQuery.InitSocket();  await mcQuery.GetHandshake();  return await mcQuery.GetFullStatus();}

Здесь создаётся разовое соединение. Для долгоживущего потребуется проверять состояние сокета и инициализировать заново (об этом в конце статьи).

Для того, чтобы пакет отправить, его надо для начала сформировать. Этим будет заниматься класс Request.

public class Request{// Набор констант для формирования пакета    private static readonly byte[] Magic = { 0xfe, 0xfd };    private static readonly byte[] Challenge = { 0x09 };    private static readonly byte[] Status = { 0x00 };      public byte[] Data { get; private set; }        private Request(){}    public byte RequestType => Data[2];    public static Request GetHandshakeRequest(SessionId sessionId)    {        var request = new Request();              // Собираем пакет        var data = new List<byte>();        data.AddRange(Magic);        data.AddRange(Challenge);        data.AddRange(sessionId.GetBytes());                request.Data = data.ToArray();        return request;    }    public static Request GetBasicStatusRequest(SessionId sessionId, byte[] challengeToken)    {        if (challengeToken == null)        {            throw new ChallengeTokenIsNullException();        }                    var request = new Request();                var data = new List<byte>();        data.AddRange(Magic);        data.AddRange(Status);        data.AddRange(sessionId.GetBytes());        data.AddRange(challengeToken);                request.Data = data.ToArray();        return request;    }        public static Request GetFullStatusRequest(SessionId sessionId, byte[] challengeToken)    {        if (challengeToken == null)        {            throw new ChallengeTokenIsNullException();        }                var request = new Request();                var data = new List<byte>();        data.AddRange(Magic);        data.AddRange(Status);        data.AddRange(sessionId.GetBytes());        data.AddRange(challengeToken);        data.AddRange(new byte[] {0x00, 0x00, 0x00, 0x00}); // Padding                request.Data = data.ToArray();        return request;    }}

Здесь всё просто. Храним все константы внутри класса и формируем пакет в трёх статических методах. Можно ещё заметить класс SessionId, который может давать как байтовое, так и строковое представление по необходимости.

public class SessionId{    private readonly byte[] _sessionId;    public SessionId (byte[] sessionId)    {        _sessionId = sessionId;    }// Случайный SessionId    public static SessionId GenerateRandomId()    {        var sessionId = new byte[4];        new Random().NextBytes(sessionId);        sessionId = sessionId.Select(@byte => (byte)(@byte & 0x0F)).ToArray();        return new SessionId(sessionId);    }    public string GetString()    {        return BitConverter.ToString(_sessionId);    }    public byte[] GetBytes()    {        var sessionId = new byte[4];        Buffer.BlockCopy(_sessionId, 0, sessionId, 0, 4);        return sessionId;    }}

Дождавшись ответа сервера, мы получаем последовательность байт, которые хотим привести к человекочитаемому виду. Для этого служит класс Response, который представляет набор "парсеров" в виде статических полей.

public static class Response{public static byte ParseType(byte[] data){return data[0];}  // public static SessionId ParseSessionId(byte[] data){if (data.Length < 1) throw new IncorrectPackageDataException(data);var sessionIdBytes = new byte[4];Buffer.BlockCopy(data, 1, sessionIdBytes, 0, 4);return new SessionId(sessionIdBytes);}public static byte[] ParseHandshake(byte[] data){if (data.Length < 5) throw new IncorrectPackageDataException(data);var response = BitConverter.GetBytes(int.Parse(Encoding.ASCII.GetString(data, 5, data.Length - 6)));if (BitConverter.IsLittleEndian){response = response.Reverse().ToArray();}return response;}public static ServerBasicState ParseBasicState(byte[] data){if (data.Length <= 5)throw new IncorrectPackageDataException(data);var statusValues = new Queue<string>();short port = -1;data = data.Skip(5).ToArray(); // Skip Type + SessionIdvar stream = new MemoryStream(data);var sb = new StringBuilder();int currentByte;int counter = 0;while ((currentByte = stream.ReadByte()) != -1){if (counter > 6) break;      // Парсим нормер портаif (counter == 5){byte[] portBuffer = {(byte) currentByte, (byte) stream.ReadByte()};if (!BitConverter.IsLittleEndian)portBuffer = portBuffer.Reverse().ToArray();port = BitConverter.ToInt16(portBuffer); // Little-endian shortcounter++;continue;}      // Парсим параметры-строкиif (currentByte == 0x00){string fieldValue = sb.ToString();statusValues.Enqueue(fieldValue);sb.Clear();counter++;}else sb.Append((char) currentByte);}var serverInfo = new ServerBasicState{Motd = statusValues.Dequeue(),GameType = statusValues.Dequeue(),Map = statusValues.Dequeue(),NumPlayers = int.Parse(statusValues.Dequeue()),MaxPlayers = int.Parse(statusValues.Dequeue()),HostPort = port,HostIp = statusValues.Dequeue(),};return serverInfo;}  // "Секции" пакета резделены константными последовательностями байт,  // это можно испльзовать для проверки, что мы всё сделали правильноpublic static ServerFullState ParseFullState(byte[] data){var statusKeyValues = new Dictionary<string, string>();var players = new List<string>();var buffer = new byte[256];Stream stream = new MemoryStream(data);stream.Read(buffer, 0, 5); // Read Type + SessionIDstream.Read(buffer, 0, 11); // Padding: 11 bytes constantvar constant1 = new byte[] {0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00};for (int i = 0; i < constant1.Length; i++)Debug.Assert(constant1[i] == buffer[i], "Byte mismatch at " + i + " Val :" + buffer[i]);var sb = new StringBuilder();string lastKey = string.Empty;int currentByte;while ((currentByte = stream.ReadByte()) != -1){if (currentByte == 0x00){if (!string.IsNullOrEmpty(lastKey)){statusKeyValues.Add(lastKey, sb.ToString());lastKey = string.Empty;}else{lastKey = sb.ToString();if (string.IsNullOrEmpty(lastKey)) break;}sb.Clear();}else sb.Append((char) currentByte);}stream.Read(buffer, 0, 10); // Padding: 10 bytes constantvar constant2 = new byte[] {0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00};for (int i = 0; i < constant2.Length; i++)Debug.Assert(constant2[i] == buffer[i], "Byte mismatch at " + i + " Val :" + buffer[i]);while ((currentByte = stream.ReadByte()) != -1){if (currentByte == 0x00){var player = sb.ToString();if (string.IsNullOrEmpty(player)) break;players.Add(player);sb.Clear();}else sb.Append((char) currentByte);}ServerFullState fullState = new(){Motd = statusKeyValues["hostname"],GameType = statusKeyValues["gametype"],GameId = statusKeyValues["game_id"],Version = statusKeyValues["version"],Plugins = statusKeyValues["plugins"],Map = statusKeyValues["map"],NumPlayers = int.Parse(statusKeyValues["numplayers"]),MaxPlayers = int.Parse(statusKeyValues["maxplayers"]),PlayerList = players.ToArray(),HostIp = statusKeyValues["hostip"],HostPort = int.Parse(statusKeyValues["hostport"]),};return fullState;}}

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

Долгоживущие приложения на основе библиотеки

Вернёмся к том, о чем я говорил выше. Это можно реализовать таким образом. Код взят из моего нотификатора пользовательской активности. Здесь каждые 5 секунд запрашивается FullStatus, поэтому имеет смысл обновлять ChallengeToken периодически сразу после истечения предыдущего. Всего приложение имеет 2 режима работы: штатный и режим восстановления соединения.

В штатном режиме приложение по таймерам обновляет токен и запрашивает FullStatus. При обнаружении упавшего сервера/оборванного соединения/etc (5 попыток передачи) приложение переходит в режим восстановления соединения и при удачной попытке получения сообщения снова возвращается в штатный режим.

Для начала напишем конструктор и два метода для запуска прослушивания сервера и окончания.

public StatusWatcher(string serverName, string host, int queryPort){    ServerName = serverName;    _mcQuery = new McQuery(Dns.GetHostAddresses(host)[0], queryPort);    _mcQuery.InitSocket();}public async Task Unwatch(){    await UpdateChallengeTokenTimer.DisposeAsync();    await UpdateServerStatusTimer.DisposeAsync();}public async void Watch(){  // Обновляем challengetoken по таймеру каждые 30 секунд    UpdateChallengeTokenTimer = new Timer(async obj =>    {        if (!IsOnline) return;                if(Debug)            Console.WriteLine($"[INFO] [{ServerName}] Send handshake request");        try        {            var challengeToken = await _mcQuery.GetHandshake();                      // Если всё ок, говорим, что мы в онлайне и сбрасываем счетчик попыток            IsOnline = true;                      lock (_retryCounterLock)            {                RetryCounter = 0;            }                        if(Debug)                Console.WriteLine($"[INFO] [{ServerName}] ChallengeToken is set up: " + BitConverter.ToString(challengeToken));        }              // Если что-то не так, увеличиваем счетчик неудачных попыток        catch (Exception ex)        {            if (ex is SocketException || ex is McQueryException || ex is ChallengeTokenIsNullException)            {                if(Debug)                    Console.WriteLine($"[WARNING] [{ServerName}] [UpdateChallengeTokenTimer] Server doesn't response. Try to reconnect: {RetryCounter}");                if(ex is McQueryException)                    Console.Error.WriteLine(ex);                                lock (_retryCounterLock)                {                    RetryCounter++;                    if (RetryCounter >= RetryMaxCount)                    {                        RetryCounter = 0;                        WaitForServerAlive(); // Переходим в режим восстановления соединения                    }                }            }            else            {                throw;            }        }            }, null, 0, GettingChallengeTokenInterval);            // По таймеру запрашиваем текущее состояние    UpdateServerStatusTimer = new Timer(async obj =>    {        if (!IsOnline) return;                if(Debug)            Console.WriteLine($"[INFO] [{ServerName}] Send full status request");        try        {            var response = await _mcQuery.GetFullStatus();                        IsOnline = true;            lock (_retryCounterLock)            {                RetryCounter = 0;            }                        if(Debug)                Console.WriteLine($"[INFO] [{ServerName}] Full status is received");                        OnFullStatusUpdated?.Invoke(this, new ServerStateEventArgs(ServerName, response));        }              // По аналогии с предыдущим        catch (Exception ex)        {            if (ex is SocketException || ex is McQueryException || ex is ChallengeTokenIsNullException)            {                if(Debug)                    Console.WriteLine($"[WARNING] [{ServerName}] [UpdateServerStatusTimer] Server doesn't response. Try to reconnect: {RetryCounter}");                if(ex is McQueryException)                    Console.Error.WriteLine(ex);                                lock (_retryCounterLock)                {                    RetryCounter++;                    if (RetryCounter >= RetryMaxCount)                    {                        RetryCounter = 0;                        WaitForServerAlive();                    }                }            }                        else            {                throw;            }        }            }, null, 500, GettingStatusInterval);}

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

public async void WaitForServerAlive(){    if(Debug)        Console.WriteLine($"[WARNING] [{ServerName}] Server is unavailable. Waiting for reconnection...");  // Отключаем отслеживание    IsOnline = false;    await Unwatch();    _mcQuery.InitSocket(); // Пересоздаём сокет    Timer waitTimer = null;    waitTimer = new Timer(async obj => {        try        {            await _mcQuery.GetHandshake();          // Говорим, что можно возвращаться в штатный режим и отключаем таймер            IsOnline = true;            Watch();            lock (_retryCounterLock)            {                RetryCounter = 0;            }            waitTimer.Dispose();        }            // Пересоздаем сокет каждые 5 (настраивается) неудачных соединений        catch (SocketException)        {            if(Debug)                Console.WriteLine($"[WARNING] [{ServerName}] [WaitForServerAlive] Server doesn't response. Try to reconnect: {RetryCounter}");            lock (_retryCounterLock)            {                RetryCounter++;                if (RetryCounter >= RetryMaxCount)                {                    if(Debug)                        Console.WriteLine($"[WARNING] [{ServerName}] [WaitForServerAlive] Recreate socket");                    RetryCounter = 0;                    _mcQuery.InitSocket();                }            }        }    }, null, 500, 5000);}
Подробнее..

УКЭП с TSP, OSCP и C .NET Core 3.1

13.03.2021 18:22:01 | Автор: admin
Я построю свой собственный сервис для подписания документов - FOX Я построю свой собственный сервис для подписания документов - FOX

Важное вступление

В гайде описывается формирование отсоединенной подписи вформате PKCS7 (рядом с файлом появится файл в формате .sig). Такую подпись может запросить нотариус, ЦБ и любой кому нужно долгосрочное хранения подписанного документа. Удобство такой подписи в том, что при улучшении ее до УКЭП CAdES-X Long Type 1 (CMS Advanced Electronic Signatures [1]) в нее добавляется штамп времени, который генерирует TSA (Time-Stamp Protocol [2]) и статус сертификата на момент подписания (OCSP [3]) - подлинность такой подписи можно подтвердить по прошествии длительного периода (Усовершенствованная квалифицированная подпись [4]).

Код основан на репозиториях corefx и DotnetCoreSampleProject - в последнем проще протестировать свои изменения перед переносом в основной проект и он будет отправной точкой по сборке corefx. Судя по записям с форума компании [5], решение для .NET Core в стадии бета-тестирования. Далее по тексту я также буду ссылаться на этот форум. Разработка велась в Visual Studio Community 2019.

Для получения штампа времени использован TSP-сервис http://qs.cryptopro.ru/tsp/tsp.srf

Что имеем на входе?

  1. КриптоПро CSP версии 5.0 - для поддержки Российских криптографических алгоритмов (подписи, которые выпустили в аккредитованном УЦ в РФ)

  2. КриптоПро TSP Client 2.0 - нужен для штампа времени

  3. КриптоПро OCSP Client 2.0 - проверит не отозван ли сертификат на момент подписания

  4. КриптоПро .NET Client - таков путь

  5. Любой сервис по проверке ЭП - я использовал Контур.Крипто как основной сервис для проверки ЭП и КриптоАРМ как локальный. А еще можно проверить ЭП на сайте Госуслуг

  6. КЭП по ГОСТ Р 34.11-2012/34.10-2012 256 bit, которую выпустил любой удостоверяющий центр

Лицензирование ПО и версии
  1. КриптоПро CSP версии 5.0 - у меня установлена версия 5.0.11944 КС1, лицензия встроена в ЭП.

  2. КриптоПро TSP Client 2.0 и КриптоПро OCSP Client 2.0 - лицензии покупается отдельно, а для гайда мне хватило демонстрационного срока.

  3. КриптоПро .NET Client версии 1.0.7132.2 - в рамках этого гайда я использовал демонстрационную версию клиентской части и все действия выполнялись локально. Лицензию на сервер нужно покупать отдельно.

  4. Контур.Крипто бесплатен, но требует регистрации. В нем также можно подписать документы КЭП, УКЭП и проверить созданную подпись загрузив ее файлы.

Так, а что надо на выходе?

А на выходе надо получить готовое решение, которое сделает отсоединенную ЭП в формате .sig со штампом времени на подпись и доказательством подлинности. Для этого зададим следующие критерии:

  1. ЭП проходит проверку на протале Госуслуг, через сервис для подтверждения подлинности ЭП формата PKCS#7 в электронных документах;

  2. КриптоАРМ после проверки подписи

    1. Заполнит поле "Время создания ЭП" - в конце проверки появится окно, где можно выбрать ЭП и кратко посмотреть ее свойства

      Стобец "Время создация ЭП"Стобец "Время создация ЭП"
    2. В информации о подписи и сертификате (двойной клик по записе в таблице) на вкладке "Штампы времени" в выпадающем списке есть оба значения и по ним заполнена информация:

      1. Подпись:

      2. Доказательства подлинности:

    3. В протоколе проверки подписи есть блоки "Доказательства подлинности", "Штамп времени на подпись" и "Время подписания". Для сравнения: если документ подписан просто КЭП, то отчет по проверке будет достаточно коротким в сравнении с УКЭП.

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

    Усовершенствованная подпись подтвержденаУсовершенствованная подпись подтверждена

Соберем проект с поддержкой ГОСТ Р 34.11-2012 256 bit

Гайд разделен на несколько этапов. Основная инструкция по сборке опубликована вместе с репозиторием DotnetCoreSampleProject - периодически я буду на нее ссылаться.

Первым делом создадим новую папку

... и положим туда все необходимое.

Инструкция делится на 2 этапа - мне пришлось выполнить оба, чтобы решение заработало. В папку добавьте подпапки .\runtime и .\packages

I - Сборка проекта без сборки corefx для Windows

  1. Установите КриптоПро 5.0 и убедитесь, что у вас есть действующая лицензия. - для меня подошла втроенная в ЭП;

  2. Установите core 3.1 sdk и runtime и распространяемый пакет Visual C++ для Visual Studio 2015 обычно ставится вместе со студией; прим.: на II этапе мне пришлось через установщик студии поставить дополнительное ПО для разработки на C++ - сборщик требует предустановленный DIA SDK.

  3. Задайте переменной среды DOTNET_MULTILEVEL_LOOKUP значение 0 - не могу сказать для чего это нужно, но в оригинальной инструкции это есть;

  4. Скачайте 2 файла из релиза corefx (package_windows_debug.zip и runtime-debug-windows.zip) - они нужны для корректной сборки проекта. В гайде рассматривается версия v3.1.1-cprocsp-preview4.325 от 04.02.2021:

    1. package_windows_debug.zip распакуйте в .\packages

    2. runtime-debug-windows.zip распакуйте в .\runtime

  5. Добавьте источник пакетов NuGet в файле %appdata%\NuGet\NuGet.Config - источник должен ссылаться на путь .\packages в созданной вами папке. Пример по добавлению источника есть в основной инструкеии. Для меня это не сработало, поэтому я добавил источник через VS Community;

  6. Склонируйте NetStandard.Library в .\ и выполните PowerShell скрипт (взят из основной инструкции), чтобы заменить пакеты в $env:userprofile\.nuget\packages\

    git clone https://github.com/CryptoProLLC/NetStandard.LibraryNew-Item -ItemType Directory -Force -Path "$env:userprofile\.nuget\packages\netstandard.library"Copy-Item -Force -Recurse ".\NetStandard.Library\nugetReady\netstandard.library" -Destination "$env:userprofile\.nuget\packages\"
    
  7. Склонируйте репизиторий DotnetCoreSampleProject в .\

  8. Измените файл.\DotnetSampleProject\DotnetSampleProject.csproj - для сборок System.Security.Cryptography.Pkcs.dll и System.Security.Cryptography.Xml.dll укажите полные пути к .\runtime;

  9. Перейдите в папку проекта и попробуйте собрать решение. Я собирал через Visual Studio после открытия проекта.

II - Сборка проекта со сборкой corefx для Windows

  1. Выполните 1-3 и 6-й шаги из I этапа;

  2. Склонируйте репозиторий corefx в .\

  3. Выполните сборку запустив .\corefx\build.cmd - на этом этапе потребуется предустановленный DIA SDK

  4. Выполните шаги 5, 7-9 из I этапа. Вместо условного пути .\packages укажите .\corefx\artifacts\packages\Debug\NonShipping, а вместо .\runtime укажите .\corefx\artifacts\bin\runtime\netcoreapp-Windows_NT-Debug-x64

На этом месте у вас должно получиться решение, которое поддерживает ГОСТ Р 34.11-2012 256 bit.

Немного покодим

Потребуется 2 COM библиотеки: "CAPICOM v2.1 Type Library" и "Crypto-Pro CAdES 1.0 Type Library". Они содержат необходимые объекты для создания УКЭП.

В этом примере будет подписываться BASE64 строка, содержащая в себе PDF-файл. Немного доработав код можно будет подписать hash-значение этого фала.

Основной код для подписания был взят со страниц Подпись PDF с помощью УЭЦП- Page 2 (cryptopro.ru) и Подпись НЕОПРЕДЕЛЕНА при создании УЭЦП для PDF на c# (cryptopro.ru), но он использовался для штампа подписи на PDF документ. Код из этого гайда переделан под сохранение файла подписи в отдельный файл.

Условно процесс можно поделить на 4 этапа:

  1. Поиск сертификата в хранилище - я использовал поиск по отпечатку в хранилище пользователя;

  2. Чтение байтов подписанного файла;

  3. Создание УКЭП;

  4. Сохранение файла подписи рядом с файлом.

using CAdESCOM;using CAPICOM;using System;using System.Globalization;using System.IO;using System.Security.Cryptography;using System.Security.Cryptography.X509Certificates;using System.Security.Cryptography.Xml;using System.Text;using System.Threading.Tasks;using System.Xml;public static void Main(){  //Сертификат для подписиX509Certificate2 gostCert = GetX509Certificate2("отпечаток");  //Файл, который предстоит подписать  byte[] fileBytes = File.ReadAllBytes("C:\\Тестовое заявление.pdf");  //Файл открепленной подписи  byte[] signatureBytes = SignWithAdvancedEDS(fileBytes, gostCert);  //Сохранение файла подписи  File.WriteAllBytes("C:\\Users\\mikel\\Desktop\\Тестовое заявление.pdf.sig", signatureBytes);}//Поиск сертификата в хранилищеpublic static X509Certificate2 GetX509Certificate2(string thumbprint){  X509Store store = CreateStoreObject("My", StoreLocation.CurrentUser);  store.Open(OpenFlags.ReadOnly);  X509Certificate2Collection certCollection =    store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false);  X509Certificate2Enumerator enumerator = certCollection.GetEnumerator();  X509Certificate2 gostCert = null;  while (enumerator.MoveNext())    gostCert = enumerator.Current;  if (gostCert == null)    throw new Exception("Certificiate was not found!");  return gostCert;}//Создание УКЭПpublic static byte[] SignWithAdvancedEDS(byte[] fileBytes, X509Certificate2 certificate){  string signature = "";    try  {    string tspServerAddress = @"http://qs.cryptopro.ru/tsp/tsp.srf";    CPSigner cps = new CPSigner();    cps.Certificate = GetCAPICOMCertificate(certificate.Thumbprint);    cps.Options = CAPICOM_CERTIFICATE_INCLUDE_OPTION.CAPICOM_CERTIFICATE_INCLUDE_WHOLE_CHAIN;    cps.TSAAddress = tspServerAddress;    CadesSignedData csd = new CadesSignedData();    csd.ContentEncoding = CADESCOM_CONTENT_ENCODING_TYPE.CADESCOM_BASE64_TO_BINARY;    csd.Content = Convert.ToBase64String(fileBytes);    //Создание и проверка подписи CAdES BES    signature = csd.SignCades(cps, CADESCOM_CADES_TYPE.CADESCOM_CADES_BES, true, CAdESCOM.CAPICOM_ENCODING_TYPE.CAPICOM_ENCODE_BASE64);    csd.VerifyCades(signature, CADESCOM_CADES_TYPE.CADESCOM_CADES_BES, true);        //Дополнение и проверка подписи CAdES BES до подписи CAdES X Long Type 1     //(вторая подпись остается без изменения, так как она уже CAdES X Long Type 1)    signature = csd.EnhanceCades(CADESCOM_CADES_TYPE.CADESCOM_CADES_X_LONG_TYPE_1, tspServerAddress, CAdESCOM.CAPICOM_ENCODING_TYPE.CAPICOM_ENCODE_BASE64);    csd.VerifyCades(signature, CADESCOM_CADES_TYPE.CADESCOM_CADES_X_LONG_TYPE_1, true);  }  catch (Exception ex)  {    throw ex;  }  return Convert.FromBase64String(signature);}

Пробный запуск

Для подписания возьмем PDF-документ, который содержит надпись "Тестовое заявление.":

Больше для теста нам ничего не надоБольше для теста нам ничего не надо

Далее запустим программу и дождемся подписания файла:

Готово. Теперь можно приступать к проверкам.

Проверка в КриптоАРМ

Время создания ЭП заполнено:

Штамп времени на подпись есть:

Доказательства подлинности также заполнены:

В протоколе проверки есть блоки "Доказательства подлинности", "Штамп времени на подпись" и "Время подписания":

Важно отметить, что серийный номер параметров сертификата принадлежит TSP-сервису http://qs.cryptopro.ru/tsp/tsp.srf

Проверка на Госуслугах

Проверка в Контур.Крипто

Done.

Гайд написан с исследовательской целью - проверить возможность подписания документов УКЭП с помощью самописного сервиса на .NET Core 3.1 с формированием штампов подлинности и времени подписания документов.

Безусловно это решение не стоит брать в работу "как есть" и нужны некоторые доработки, но в целом оно работает и подписывает документы подписью УКЭП.

Это вообще законно?

С удовольствием узнаю ваше мнение в комментариях.

Ссылки на публичные источники

[1] CMS Advanced Electronic Signatures (CAdES) - https://tools.ietf.org/html/rfc5126#ref-ISO7498-2

[2] Internet X.509 Public Key Infrastructure Time-Stamp Protocol (TSP) - https://www.ietf.org/rfc/rfc3161.txt

[3] X.509 Internet Public Key Infrastructure Online Certificate Status Protocol - OCSP - https://tools.ietf.org/html/rfc2560

[4] Усовершенствованная квалифицированная подпись Удостоверяющий центр СКБ Контур (kontur.ru)

[5] Поддержка .NET Core (cryptopro.ru)

[6] http://qs.cryptopro.ru/tsp/tsp.srf - TSP-сервис КриптоПро

UPD1: Поменял в коде переменную, куда записываются байты файла подписи.

Также я забыл написать немного про подпись штампа времени - он подписывается сертификатом владельца TSP-сервиса. По гайду это ООО "КРИПТО-ПРО":

Подробнее..

Страсти по Serilog .NET Core Глобальный логгер

04.04.2021 12:12:21 | Автор: admin

Serilog на данный момент, пожалуй, самая популярная библиотека логирования для .NET. Зародилась эта библиотека ещё до появления платформы .NET Core, в которой разработчики платформы предложили своё видение подсистемы логирования приложения. В 2017 году Serilog создаёт библиотеку для интеграции в подсистему логирования .NET Core.

В этой серии статей мы пристально рассмотрим и проанализируем проблемы использования Serilog в .NET Core и постараемся ответить на вопрос как их решить?

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

Введение

В феврале 2013 года на github.com появился проект Opi, который уже через 6 дней получил знакомое имя Serilog. Этот проект изначально разрабатывался под .NET Framework 4.5. На момент его разработки, платформа .NET не предлагала из коробки никакого встроенного API логирования. На тот момент самыми популярными инструментами для решения этой задачи были NLog и log4net.

Статистика популярности log4net и NLog 2012-2014 гг.Статистика популярности log4net и NLog 2012-2014 гг.

График на google trends.

Ещё до официального выхода первой версии .NET Core (27.06.2016) уже шли разговоры о поддержке этой платформы (как, например, тут). Сейчас уже сложно разобраться в подробностях тех времён и как начиналась поддержка .Net Core. Ясность наступает в августе 2017, когда на github.com был создан проект serilog-aspnetcore. Он изначально был разработан под .NET Standard 2.0, т.е. уже поддерживал использование в проектах .NET Core 2.0.

Но это была не переработка под .NET Core, а интеграция инструментов основной библиотеки Serilog в подсистему логирования .NET Core. Эта интеграция не подразумевала пересмотра подсистемы логирования Serilog через призму подходов, которые предлагает платформа .NET Core, как для расширения API логирования, так и для взаимодействия компонент приложения.

Предисловие

При написании статьи эксперименты проводились на платформе .NET Core 3.1. Модульные тесты написаны под xUnit. Для анализа использовались исходники актуальных на момент написания статьи версий библиотек Serilog:

Интеграция Serilog + .NET Core

В 100% случаев, когда Я сталкивался с применением Serilog, это был код одного из его примеров.

Код примера
public static int Main(string[] args){    Log.Logger = new LoggerConfiguration()        .WriteTo.Console()        .CreateBootstrapLogger();    Log.Information("Starting up!");    try    {        CreateHostBuilder(args).Build().Run();        Log.Information("Stopped cleanly");        return 0;    }    catch (Exception ex)    {        Log.Fatal(ex, "An unhandled exception occured during bootstrapping");        return 1;    }    finally    {        Log.CloseAndFlush();    }}public static IHostBuilder CreateHostBuilder(string[] args) =>    Host.CreateDefaultBuilder(args)    .UseSerilog((context, services, configuration) => configuration                .ReadFrom.Configuration(context.Configuration)                .ReadFrom.Services(services)                .Enrich.FromLogContext()                .WriteTo.Console())    .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });

В этом примере мы видим, как разработчики Serilog предлагают его использовать:

  • метод Main:

    • создаём глобальный статический логгер и конфигурируем для вывода в консоль. Это временно, пока не будет применена конфигурация приложения;

    • сообщаем о запуске приложения через глобальный логгер

    • запускаем хост приложения;

    • сообщаем об успешном останове приложения через глобальный логгер;

    • при необходимости логируем ошибку запуска приложения через глобальный логгер;

    • закрываем и флашим глобальный логгер;

  • метод CreateHostBuilder:

    • интегрируем и конфигурируем Serilog, в том числе с помощью конфигурации приложения.

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

Далее в сервисах приложения, в контроллерах, если это вэб приложение, которые создаются с применением DI, можно получить логгер из подсистемы логирования .NET Core, записать туда лог и он попадёт в логгер Serilog и будет записан в соответствии с конфигурацией, определённой в методе CreateHostBuilder.

Глобальный логгер Как это работает?

Как написано в примере Program.cs перед инициализацией статического логгера:

The initial bootstrap logger is able to log errors during start-up. It's completely replaced by the logger configured in UseSerilog() below, once configuration and dependency-injection have both been set up successfully.

Т.е. инициализируется глобальный логгер, который действует до успешного вызова UseSerilog() при создании HostBuilder приложения. После чего он будет заменён на логгер, собранный в соответствии с конфигурацией приложения.

Глобальный логгер в Serilog - свойство Logger статического класса Log с get и set. Имеет значение по умолчанию объект типа SilentLogger, который ничего никуда не пишет:

public static class Log{    static ILogger _logger = SilentLogger.Instance;    /// <summary>    /// The globally-shared logger.    /// </summary>    /// <exception cref="ArgumentNullException">When <paramref name="value"/> is <code>null</code></exception>    public static ILogger Logger    {        get => _logger;        set => _logger = value ?? throw new ArgumentNullException(nameof(value));    }    ...}

Теперь исследуем UseSerilog и где назначается глобальный логгер.

Проходим в UseSerilog это где-то там назначается глобальный логгер. Что делает этот метод:

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

    • глобальный логгер является логгером, который можно переконфигурировать после его создания, т.е. он наследуется от ReloadableLogger;

    • входной параметр preserveStaticLogger (не трогать статический (читай глобальный) логгер) == false;

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

  • далее, в зависимости от необходимости сохранить глобальный логгер (вх параметр preserveStaticLogger), для интеграции в подсистему логирования .NET Core используется полученный на предыдущем этапе логгер, если глобальный логгер надо сохранить. И в противном случае заменяется глобальный логгер на полученный на предыдущем этапе, а для интеграции вместо логгера передаётся null, что потом приведёт к тому, что вместо конкретного переданного для интеграции логгера будет использоваться глобальный логгер.

По умолчанию preserveStaticLogger==false, поэтому глобальный статический логгер по умолчанию заменяется. Как это происходит:

  • в фабрику логгеров Serilog передаётся неопределённый логгер (т.е. null). Эта фабрика интегрируется во встроенную в .NET Core подсистему логирования, как фабрика логгеров;

  • в фабрике создаётся поставщик логгеров Serilog и туда передаётся логгер тоже null. Этот поставщик используется фабрикой, чтобы предоставить объект логгера, реализующий платформенный интерфейс Microsoft.Extensions.Logging.ILogger;

  • поставщик логгеров создаёт и предоставляет платформенный логгер Serilog для интеграции во встроенную систему логирования .NET Core. В этот объект передаётся логгер Serilog , т.е. опять null;

  • в платформенном логгере Serilog в конструкторе происходит выбор используемого логгера Serilog : если в качестве логгера передан null, то в дальнейшем в качестве логгера Serilog будет использоваться глобальный статический логгер из Log.Logger. И уже этот объект будет использоваться для логирования непосредственно в методе логирования этого платформенного логгера Serilog : тут и тут.

Эффект нескольких логгеров

Разработчики Serilog предлагают смешанный подход к использованию Serilog в .NET Core приложениях:

  • через стандартную встроенную .NET Core подсистему логирования;

  • с использованием глобального логгера через свойство Logger публичного статического класса Serilog.Log.

При этом встраивание Serilog в .NET Core предусматривает принципиально разные комбинации конфигурирования Serilog в зависимости от способа доступа к нему:

  • чтобы логи через глобальный логгер писались так же, как через интегрированный в .NET Core (когда используется один и тот же объект preserveStaticLogger==false) как в конфигурации приложения

  • чтобы логи через глобальный логгер писались как инициировано в самом начале (в примере в консоль), а интегрированный в .NET Core как в конфигурации приложения (preserveStaticLogger==true)

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

Но всё не так плохо. Параметр preserveStaticLoggerпо умолчанию false. А это значит, если ничего специально не делать, логгер при обоих способах логирования будет один и будет иметь одну и ту же конфигурацию.

Проблемы с тестированием

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

Проверим это! Для этого понадобится вспомогательные классы.

Тестовый логгер

Тестовый логгер, который пишет в тестовый output лог-сообщение и добавляет в него префикс:

class TestLogger : Serilog.ILogger{    private readonly string _prefix;    private readonly ITestOutputHelper _output;    public TestLogger(string prefix, ITestOutputHelper output)    {    _prefix = prefix;    _output = output;    }    public void Write(LogEvent logEvent)    {    _output.WriteLine(_prefix + " " +  logEvent.MessageTemplate.Render(logEvent.Properties));    }}

Отправитель запросов

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

class ConcurrentLoggingTestRequestSender{    private readonly WebApplicationFactory<Startup> _webAppFactory;    private readonly ITestOutputHelper _output;    private readonly string _logPrefix;    public ConcurrentLoggingTestRequestSender(WebApplicationFactory<Startup> webAppFactory, ITestOutputHelper output, string logPrefix)    {        _webAppFactory = webAppFactory;        _output = output;        _logPrefix = logPrefix;    }    public async Task<HttpResponseMessage> Send()    {        var client = _webAppFactory.WithWebHostBuilder(b => b.UseSerilog(        (context, config) => config        .WriteTo.Logger(new TestLogger(_logPrefix, _output))        )).CreateClient();        return await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/ping"));    }}

В методе Send инициируется приложение с добавлением Serilog и логированием в тестовый логгер.

Тесты

Тесты минимальны, почти одинаковы и отличаются только префиксом в выводимых лог-сообщениях.

Тест1:

public class ConcurrentLoggingTest_1of2 : IClassFixture<WebApplicationFactory<Startup>>{    private readonly ConcurrentLoggingTestRequestSender _requestSender;    private readonly ITestOutputHelper _output;    public ConcurrentLoggingTest_1of2(WebApplicationFactory<Startup> waf, ITestOutputHelper output)    {        _output = output;    _requestSender = new ConcurrentLoggingTestRequestSender(waf, output, "==1==");    }    [Fact]    public async Task Test()    {        _output.WriteLine("Test 1 of 2");        _output.WriteLine("");        var resp = await _requestSender.Send();        Assert.True(resp.IsSuccessStatusCode);    }}

Тест2:

public class ConcurrentLoggingTest_2of2 : IClassFixture<WebApplicationFactory<Startup>>{    private readonly ConcurrentLoggingTestRequestSender _requestSender;    private readonly ITestOutputHelper _output;    public ConcurrentLoggingTest_2of2(WebApplicationFactory<Startup> waf, ITestOutputHelper output)    {        _output = output;    _requestSender = new ConcurrentLoggingTestRequestSender(waf, output, ">>2<<");    }    [Fact]    public async Task Test()    {        _output.WriteLine("Test 2 of 2");        _output.WriteLine("");        var resp = await _requestSender.Send();        Assert.True(resp.IsSuccessStatusCode);    }}

Результаты тестирования

Запуск тестов по отдельности
Test 1 of 2==1== Application started. Press Ctrl+C to shut down.==1== Hosting environment: "Development"==1== Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke"==1== Request starting HTTP/1.1 GET http://localhost/ping  ==1== Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").==1== Executing ObjectResult, writing value of type '"System.String"'.==1== Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 13.1068ms==1== Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Request finished in 76.8507ms 200 text/plain; charset=utf-8Test 2 of 2>>2<< Application started. Press Ctrl+C to shut down.>>2<< Hosting environment: "Development">>2<< Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke">>2<< Request starting HTTP/1.1 GET http://localhost/ping  >>2<< Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").>>2<< Executing ObjectResult, writing value of type '"System.String"'.>>2<< Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 15.2088ms>>2<< Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Request finished in 78.8673ms 200 text/plain; charset=utf-8
Запуск тестов вместе 1
Test 1 of 2Test 2 of 2>>2<< Application started. Press Ctrl+C to shut down.>>2<< Application started. Press Ctrl+C to shut down.>>2<< Hosting environment: "Development">>2<< Hosting environment: "Development">>2<< Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke">>2<< Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke">>2<< Request starting HTTP/1.1 GET http://localhost/ping  >>2<< Request starting HTTP/1.1 GET http://localhost/ping  >>2<< Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").>>2<< Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").>>2<< Executing ObjectResult, writing value of type '"System.String"'.>>2<< Executing ObjectResult, writing value of type '"System.String"'.>>2<< Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 13.5891ms>>2<< Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 13.5891ms>>2<< Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'>>2<< Request finished in 78.0903ms 200 text/plain; charset=utf-8>>2<< Request finished in 78.0958ms 200 text/plain; charset=utf-8
Запуск тестов вместе 2
Test 1 of 2==1== Application started. Press Ctrl+C to shut down.==1== Application started. Press Ctrl+C to shut down.==1== Hosting environment: "Development"==1== Hosting environment: "Development"==1== Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke"==1== Content root path: "C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke"==1== Request starting HTTP/1.1 GET http://localhost/ping  ==1== Request starting HTTP/1.1 GET http://localhost/ping  ==1== Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Executing endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").==1== Route matched with "{action = \"Ping\", controller = \"Ping\"}". Executing controller action with signature "Microsoft.AspNetCore.Mvc.IActionResult Ping()" on controller "SerilogPoke.Controllers.PingController" ("SerilogPoke").==1== Executing ObjectResult, writing value of type '"System.String"'.==1== Executing ObjectResult, writing value of type '"System.String"'.==1== Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 12.7648ms==1== Executed action "SerilogPoke.Controllers.PingController.Ping (SerilogPoke)" in 12.7649ms==1== Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Executed endpoint '"SerilogPoke.Controllers.PingController.Ping (SerilogPoke)"'==1== Request finished in 78.428ms 200 text/plain; charset=utf-8==1== Request finished in 78.4282ms 200 text/plain; charset=utf-8Test 2 of 2

Вывод по тестированию

Так как логгер глобальный и один на все тесты, а тесты выполняются параллельно, то после инициализации веб-приложения в каждом тесте, в качестве глобального логгера остаётся тот логгер, который был присвоен статическому полю Serilog.Log.Logger позже. Поскольку сдвиг по времени в работе тестов непредсказуем, то в разных случаях глобальным становится логгер из разных тестов.

Поэтому при выполнении кода в тестах надо всегда помнить и использовать при интеграции Serilog в .NET Core (метод UseSerilog()) параметр preserveStaticLogger = true, чтобы при инициализации в каждом тесте подсистемы логирования использовался в каждом случаи свой логгер. При этом будет возникать эффект нескольких логгеров с соответствующими последствиями.

Глобальный логгер где нужен?

При должном подходе, работа всего приложения сводится к взаимодействию объектов, создаваемых и связываемых по средствам DI платформой .NET Core, а точнее объектом хоста, который создаётся в примере при использовании метода CreateHostBuilder. И в этом случае самым подходящим способом логирования было бы использование встроенного механизма логирования.

Однако разработчики Serilogтак же предлагают нам так же использовать глобальный логгер в том месте, где нет DI платформы .NET Core в методе Main вне области работы объекта хоста:

  • Запуск!

  • Перехват необработанной ошибки

  • Успешное окончание

Запуск!

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

  • в консоли мы в любом случае как-то понимаем, что приложение начало запускаться;

  • Debug ну, это, скорее всего IDE и тут тоже понятно.

Вот и получается, что:

  • это не даёт понимания о начале запуска приложения, так как оно уже запущено на этот момент и находится в процессе инициализации прикладных механизмов (инициализация объекта хоста, сервисов, загрузка конфигурации и прочее);

  • после успешного запуска хоста веб приложения, хост сам отправит сообщение об успешном запуске:

[01:53:06 INF] Now listening on: http://localhost:5000[01:53:06 INF] Application started. Press Ctrl+C to shut down.[01:53:06 INF] Hosting environment: Development[01:53:06 INF] Content root path: C:\Users\ozzye\Documents\prog\my\serilog-poke\src\SerilogPoke

Вывод:

это лог-сообщение малоинформативное и избыточное

Перехват необработанной ошибки - что получаем?

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

Проведём пару тестов, в которых запустим веб-приложение а консоли и в методе конфигурации сервисов выкинем ошибку.

public class Startup{    public void ConfigureServices(IServiceCollection services)    {        throw new Exception("Ololo!");        services.AddControllers();    }}

Вывод в консоли:

Вывод в консоль при отлове необработанного исключение Вывод в консоль при отлове необработанного исключение

Теперь закомментируем перехват необработанной ошибки в Main:

public static int Main(string[] args){    Log.Logger = new LoggerConfiguration()        .WriteTo.Console()        .CreateBootstrapLogger();    Log.Information("Starting up!");    try    {        CreateHostBuilder(args).Build().Run();        Log.Information("Stopped cleanly");        return 0;    }    //catch (Exception ex)    //{    //    Log.Fatal(ex, "An unhandled exception occured during bootstrapping");    //    return 1;    //}    finally    {        Log.CloseAndFlush();    }}

Вывод в консоли:

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

Вывод:

Даже без логирования через Serilog, необработанное исключение было выведено в консоль.

Перехват необработанной ошибки - что может случиться?

Теперь проведём тесты, в которых обернём запуск веб-приложения в try-catch и используем для логирования глобальный логгер Serilog, который будет выводить сообщения с префиксом initial, а при запуске веб-приложения установим другой логгер с префиксом configured.

Тест1: ошибка на этапе конфигурирования сервисов. В этом тесте ошибка происходит при конфигуировании сервисов приложения.

//Arrangevar initialLogger = new TestLogger("initial: ", _output);var configuredLogger = new TestLogger("configured: ", _output);HttpClient client;Log.Logger = initialLogger;Log.Information("Starting up!");try{    client = _waf.WithWebHostBuilder(        builder => builder        .UseSerilog((context, config) => config                    .WriteTo.Logger(configuredLogger))        .ConfigureServices(collection =>                           {                               throw new Exception("Ololo!");                           })    ).CreateClient();}catch (Exception e){    Log.Fatal(e, "An unhandled exception occured during bootstrapping");    throw;}finally{    Log.CloseAndFlush();}//Actvar resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/ping"));//AssertAssert.True(resp.IsSuccessStatusCode);

Лог:

initial:  Starting up!configured:  An unhandled exception occured during bootstrapping

Тест2: ошибка до инициализации подсистемы логирования .NET Core. В этом тесте ошибка происходит при попытке загрузить отсутствующий конфигурационный файл.

//Arrangevar initialLogger = new TestLogger("initial: ", _output);var configuredLogger = new TestLogger("configured: ", _output);HttpClient client;Log.Logger = initialLogger;Log.Information("Starting up!");try{    client = _waf.WithWebHostBuilder(        builder => builder        .UseSerilog((context, config) =>                         config.WriteTo.Logger(configuredLogger)                       )        .ConfigureAppConfiguration((context, configurationBuilder) =>                             configurationBuilder.AddJsonFile("absent.json"))                    )            .CreateClient();}catch (Exception e){    Log.Fatal(e, "An unhandled exception occured during bootstrapping");    throw;}finally{    Log.CloseAndFlush();}//Actvar resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/ping"));//AssertAssert.True(resp.IsSuccessStatusCode);

Лог:

initial:  Starting up!initial:  An unhandled exception occured during bootstrapping

Вывод:

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

Успешное окончание

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

public static int Main(string[] args){    ...    try    {        CreateHostBuilder(args).Build().Run();        Log.Information("Stopped cleanly");        return 0;    }    catch (Exception ex)    {        ...    }    finally    {        Log.CloseAndFlush();    }}

Вывод:

Данное сообщение не соответствует действительности.

Вывод по логированию в Main

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

По опыту, ошибки на этапе запуска веб-приложения это редкая ситуация, которая быстро выявляется и устраняется, потому что при запуске приложения в основном формируется и конфигурируется объектная модель приложения. Самые, наверное, вероятные ситуации:

  • нет какой-то библиотеки

  • нет конфига

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

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

Глобальный логгер Вред

Глобальный логгер это объект, через который осуществляется логирование в Serilog, присвоенный публичному статическому свойству Logger статического класса Serilog.Log. Это свойство инициализируется при запуске приложения с конфигурацией в коде, а затем второй раз инициализируется при инициализации хоста веб-приложения на основе загруженной конфигурации.

Таким образом это не Singleton в чистом виде. Но в пределах работы веб-приложения он задумывается как единственный глобальный экземпляр.

Проблемы:

  • потеря лог-сообщений в модульных тестах. В зависимости от особенностей интеграции Serilog в .NET Core (параметр preserveStaticLogger в методе UseSerilog()) могут возникнуть проблемы:

  • по тем же причинам могут не задействоваться средства расширения подсистемы логирования, тестируемые в модульных тестах в составе развёрнутой подсистемы логирования .NET Core и Serilog

  • возможность в любой момент использовать Serilog.Log.Logger мимо системы DI от .NET Core, которая реализуется под самыми весомыми предлогами:

    • это временно;

    • а ну чё, и так же работает;

    • Я так привык на .NET Framework;

  • как следствие, использование в коде статического сервис-локатора - антипаттерн;

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

  • в библиотеке присутствует нехарактерное решение для .NET Core. Сильно напоминает антипаттерн Boat Anchor .

Заключение

Статистика популярности log4net, NLog и Serilog 2013-2021 гг.Статистика популярности log4net, NLog и Serilog 2013-2021 гг.

График на google trends.

Serilog был и есть отличным инструментом. Но он не был переработан и адаптирован в полной мере для .NET Core, поэтому в нём остались механизмы, которые больше характерны для решений на .NET Framework. Как показывает прогресс, .NET, в виде .NET 5, двигается в направлении архитектуры, взятой из .NET Core. Поэтому разработчикам Serilog рано или поздно придётся заняться вопросом адаптации более плотно.

К сожалению, простым запросом слияния в github проблему с глобальным логом полностью не решить. Тут требуется глобальный рефакторинг, который может, например включать такие изменения:

  • вынести из основной библиотеки Serilog все механизмы, относящиеся к .NET Framework в отдельную библиотеку на подобии serilog-aspnetcore, которая будет использовать основную библиотеку. И туда перенести в т.ч. класс Serilog.Log. тогда при подключении Serilog к проекту на .NET Core в приложении этот класс не будет доступен;

  • в serilog-aspnetcore в методе интеграции Serilog в подсистему логирования .NET Core всегда использовать алгоритм работы, соответствующий тому случаю, если указать вх. параметр preserveStaticLogger = true, т.е. вместо глобального логгера создать новый.

В данной статье мы разобрались с тем, какое место в логировании через Serilog в .NET Core занимает глобальный логгер и какие проблемы он может принести.

В следующих статьях будут разобраны другие особенности интеграции Serilog в .NET Core.

Подробнее..
Категории: C , Net , Net core , Logging , Log , Logger , Serilog

Добавляем CRUD в ASP.NET Core проект за 10 минут с помощью EasyData

19.04.2021 06:22:01 | Автор: admin

image


Одной из первых задач для большинства бизнес-приложений на ASP.NET Core является реализация операций CRUD (Create, Read, Update, Delete) для основных объектов, с которыми работает ваше решение.


Каждый разработчик, которому нужно решить эту задачу, знает, что создание CRUD-страниц и форм очень скучный и трудоемкий процесс.
Если делать это вручную, то получится очень медленно и наверняка с кучей недоработок (пропущенные поля, забытые валидаторы и т.д.).
Можно воспользоваться инструментом scaffolding'а, доступным в Visual Studio. Но даже в этом случае это будет совсем не быстрый процесс, поскольку его нужно запускать для каждого класса модели. В итоге вы получаете множество .cs/.cshtml файлов, которые нужно поддерживать и атуализировать по мере изменений в классах модели или просто когда нужно что-то исправить в поведении или внешнем виде CRUD страниц. Если количество сущностей в вашей БД превышает десяток, то весьма велики шансы того, что файлы для реализации CRUD операций занимают больше 50% всей кодовой базы вашего проекта. Более того это решение все равно не обеспечивает некоторых важных, а порой и необходимых функций, таких как разбитие на страницы в режиме просмотра (pagination) или банальные поиск/фильтрация.


Решение: использовать библиотеку с открытым кодом EasyData, о которой и пойдет речь в данной статье.


Что такое EasyData?


EasyData и была создана для решения большинства (если не всех) проблем описанных выше. Это библиотека с открытым кодом, распространяется по MIT лицензии, исходники доступны на GitHub. Главной особенностью является использование декларативного подхода.
Весь процесс можно разделить на два основных этапа:


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

  • На основе этой информации библиотека EasyData разворачивает Web API для CRUD-операций и пользовательский интерфейс на основе чистого (ванильного) JavaScript, что позволяет вашим пользователям выполнять все те операции.

Самое замачательное здесь то, что в случае использования Entity Framework Core для первого шага (описания данных) вам нужен только ваш DbContext! Вы просто скармливаете его библиотеке, и EasyData автоматически извлекает оттуда всю необходимую информацию для разворачивания CRUD API и пользовательского интерфейса.


Весь процесс занимает всего несколько минут и около 10 строк кода:


EasyData quick demo


Подключаем EasyData в свой проект


Прежде всего, чтобы попробовать EasyData в работе, вы можете открыть и запустить один из примеров проектов, доступных на GitHub.


Для установки EasyData в собственный проект надо выполнить следующие 3 простых шага:


1. Устанавливаем NuGet пакеты EasyData


  • EasyData.AspNetCore
  • EasyData.EntityFrameworkCore.Relational

2. Добавляем EasyData middleware в Startup.Configure:


using EasyData.Services;.    .    .    .    .    app.UseEndpoints(endpoints => {        endpoints.MapEasyData(options => {            options.UseDbContext<AppDbContext>();        });        endpoints.MapRazorPages();    });

В опциях нашего middleware мы указываем класс объекта DbContext, который и будет использоваться как источник метаданных.


3. Настраиваем страницу CRUD операций


Если вы используете Razor Pages, добавьте новую страницу (например, EasyData.chstml). Если это MVC, вам понадобятся новый контроллер и соответствующий ему view.


Наша новая страница должна будет ловить все адреса, которые начинаются с определенного префикса (/easydata/ по умолчанию, но это можно настроить). Для этого мы используем специальный catch-all параметр в определении маршрута: "/easydata/{**entity}".


Кроме того, мы также добавляем .css и .js файлы EasyData (easydata.min.css и easydata.min.js), которые обеспечивают отрисовку интерфейса управления данными и обработку всех CRUD-операций на стороне клиента.


@page "/easydata/{**entity}"@{    ViewData["Title"] = "EasyData";}<link rel="stylesheet" href="http://personeltest.ru/aways/cdn.korzh.com/ed/1.2.4/easydata.min.css" /><div id="EasyDataContainer"></div>@section Scripts {    <script src="http://personeltest.ru/aways/cdn.korzh.com/ed/1.2.4/easydata.min.js" type="text/javascript"></script>    <script>        window.addEventListener('load', function () {            new easydata.crud.EasyDataViewDispatcher().run()        });    </script>}

Вот и все. Теперь вы можете запустить свой проект, открыть URL-адрес /easydata и наслаждаться функциями CRUD.


Вот как это выглядит в итоге:



Страница просмотра значений для некоторой сущности (в данном случае, Orders)



Диалог редактирования одной записи



Lookup диалог, который был открыт из диалога редактирования записи


Как это работает


Коротко о том, как работает вся эта магия.


Как мы уже упоминали ранее, EasyData решает 3 основных задачи:


  • Собирает метаданные из нашей базы данных.
  • Устанавливает API для основных CRUD операций.
  • Визуализирует интерфейс (опять же, на основе метаданных) и обрабатывает все взаимодействие пользователя с этим интерфейсом.

Давайте изучим все эти части более подробно.


Метаданные


Метаданные это данные о ваших данных: какие сущности (таблицы) хранятся в вашей базе данных, как они связаны, какие имеют атрибуты (поля) и другая полезная информация.


EasyData собирает метаданные (каким-либо способом об этом см. ниже) и сохраняет их в объекте класса MetaData. Этот объект содержит список сущностей (таблиц), атрибуты (поля) для каждой сущности, связи между сущностями и некоторую дополнительную информацию, используемую в API и при визуализации и обработке пользовательского интерфейса.


Для заполнения объекта MetaData нам нужно указать некоторый загрузчик метаданных. В нашем примере мы сделали это с помощью вызова UseDbContext для загрузки метаданных из объекта DbConext. На данный момент (в версии 1.2), это пока единственный доступный загрузчик метаданных. В будущих версиях можно будет загружать метаданные непосредственно с БД или, возможно, каким-нибудь другим способом.


EasyData middleware


EasyData middleware отвечает за обработку REST API для всех CRUD (и не только) операций, инициированных веб страницей.


Чтобы добавить middleware в очередь обработки вашего ASP.NET Core приложения используйте функцию MapEasyData в процессе настроки точек вызова (endpoints) UseEndpoints:


  app.UseEndpoints(endpoints =>    {       endpoints.MapEasyData(options => {            options.UseDbContext<AppDbContext>();        });    }

Этот вызов желательно поставить перед любым вызовом MapControllerRoute или MapRazorPages.
По умолчанию EasyData API будет откликаться по адресу /api/easydata, но вы легко можете изменить его на другой:


   endpoints.MapEasyData(options => {        options.Endpoint = "/api/my-crud";        .    .    .    .    });

Единственное, что нужно настроить для EasyData middleware, это сказать ему, откуда брать метаданные. Как уже упоминалось выше, сейчас доступен только один вариант, а именно получение метаданных с DbContext. Вот почему мы добавляем вызов UseDbContext <AppDbContext>() в приведенном выше примере. Кроме получения метаданных, UseDbContext также обеспечивает наш middlware всеми средствами для выполнения CRUD-операций (через сам объект DbContext).


Корневая страница интерфейса EasyData


В качестве такой страницы выступает Razor page или же MVC view. Эта страница должна обрабатывать все URL адреса, которые начинается с определенного префикса. По умолчанию это /easydata/ и поэтому все пути, типа /easydata/student или /easydata/invoice, должны быть обработаны этой страницей.


NB: /easydata/ префикс по умолчанию. Вы можете установить и другой путь к этой странице, но в этом случае его нужно будет указать в параметрах нашего объекта RootDispatcherView


Наша корневая страница может содержать любые элементы HTML на ваш выбор. Но для нормальной работы CRUD интерфейса, она должна включать следующие 4 элемента:


  • <link> элемент со ссылкой на CSS файл EasyData (easydata.min.cs)


  • Контейнер (пустой элемент div), где будет рисоваться наш CRUD интерфейс. По умолчанию он должен иметь идентификатор EasyDataContainer, но это также можно настроить с помощью опций.


  • <script> элемент со ссылкой на файл easydata.min.js.


  • небольшой скрипт, который создает и запускает объект EasyDataViewDispatcher при загрузке страницы.



Пример простейшей корневой страницы вы можете увидеть в разделе Подключаем EasyData в свой проект выше.


В завершение


На данный EasyData может работать с .NET Core 3.1 и .NET 5. Очевидно, поддерживаются все версии ASP.NET Core и Entity Framework Core, которые могут работать с указанными версиями .NET (Core).
Однако не будет большой проблемы при необходимости добавить также поддержку предыдущих версий .NET Core или даже .NET Framework 4.x. Если кому-то это нужно, создавайте новый issue про это в GitHub репозитории библиотеки.


EasyData был сделан, в первую очередь, для быстрого создания прототипов новых проектов или так называемых POC (proof of concept), когда уже есть некоторое понимание с какими данными придется работать, но не хочется тратить много времени на реализацию простейших операций с этими данными.


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


Ну и конечно, не забудьте поставить звездочку в EasyData репозитории на GitHub. Особенно если эта библиотека помогла сэкономить вам немного времени.

Подробнее..
Категории: C , Net , Net core , Asp.net core , Entity framework , Net 5 , Crud

XUnit тестирование в TeamCity

15.01.2021 14:04:54 | Автор: admin

Microsoft активно развивает свои проекты с открытым кодом, например, ASP.NET Core или MSBuild. Вместе с этим набирает популярность и тестовый фреймворк xUnit, используемый в них для модульного тестирования. В этой статье мы рассмотрим несколько способов запуска xUnit-тестов для непрерывной интеграции проекта средствами TeamCity.


Примеры конфигураций сборки можно найти на этом демо-сервере TeamCity, а исходный код лежит в этом репозитории: Lib это код тестируемого приложения, а Lib.Tests проект с тестами. Оба этих проекта нацелены на .NET версий net472 и netcoreapp2.1.


Для поддержки xUnit, в тестовом проекте задана NuGet-зависимость на соответствующий пакет xunit:


<PackageReference Include="xunit"/>


Этот мета-пакет не содержит бинарных файлов, а добавляет несколько зависимостей на NuGet-пакеты xunit.core, xunit.assert и xunit.analyzers. Это тестовое API xUnit. Каждый тестовый метод в xUnit помечается атрибутом [Fact] для обычных тестов или [Theory] для параметризованных тестов. Обычно, каждому тестируемому модулю соответствует свой тестовый класс с набором тестовых методов, проверяющих ту или иную логику. Каждому тестируемому проекту соответствует свой тестовый проект.


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


xUnit console runner


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


  1. Где взять xunit.console на агенте TeamCity, чтобы потом использовать его для запуска тестов?
  2. Какую версию xunit.console выбрать? Пакет xunit.runner.console содержит набор исполняемых файлов для разных версий .NET.
  3. Как быть, если нужно выполнить тесты в нескольких сборках одного тестового проекта, созданных для разных версий .NET?
  4. Как настроить сбор статистики покрытия кода? Эта статистика, конечно, не может полностью отражать качество модульного тестирования, но она может быть полезной для обнаружения кода, непокрытого тестами.
  5. Какие параметры использовать для тестовой утилиты и для сбора статистики покрытия кода?
  6. Как передать результаты тестов и статистику покрытия в TeamCity?

Рассмотрим пример конфигурации сборки TeamCity, содержащей 5 шагов, в каждом из которых мы используем ранер .NET:


image


Первым шагом решаем вопрос (1): Где взять xunit.console?:


image


Этот шаг использует команду .NET, чтобы добавить зависимость на пакет xunit.runner.console в тестовый проект Lib.Tests. При восстановлении зависимости на шаге 2 утилита xunit.console появится на агенте TeamCity. Если есть несколько тестовых проектов, то зависимость можно будет добавить только в один. Но как определить точный путь к xunit.console после его загрузки? Если ничего не предпринять, пакет будет загружен в стандартную директорию кэша NuGet-пакетов:

  • в Windows: %userprofile%\.nuget\packages
  • на Mac/Linux: ~/.nuget/packages

Эти пути известны, но они зависят от операционной системы, от аккаунта, под которым запущен агент TeamCity, и от персональных настроек среды окружения для этого аккаунта. Условия могут меняться от агента к агенту. Чтобы быть уверенным, по какому пути найдется xunit.console, лучше задать переменную среды окружения NUGET_PACKAGES со значением %teamcity.build.checkoutDir%/packages. Эта переменная определяет, где появятся NuGet-пакеты после восстановления зависимостей на следующем шаге сборки. В этом примере она указывает на произвольную директорию packages, относительно корневой директории проекта. Вот как это выглядит на странице редактирования параметров:


image


Благодаря этой переменной окружения, путь к xunit.console больше не зависит от внешних факторов. Следующий шаг довольно прост. Он строит решение (solution), восстанавливая зависимости:


image


После его выполнения, в директорию packages добавятся NuGet-пакеты всех зависимостей, включая xunit.runner.console, а в директорию Lib.Tests/bin/Debug тестовые сборки, соответствующие целевым версиям .NET. И если версия тестовой сборки в директории Lib.Tests/bin/Debug/net472 уже готова для выполнения тестов, то директория Lib.Tests/bin/Debug/netcoreapp2.1 для .NET CoreApp 2.1 не содержит всех требуемых бинарных зависимостей. Вместо этого, в ней присутствуют _JSON-_файлы с описанием того, где найти эти бинарные зависимости. Шаг 3 собирает всё вместе для приложений .NET CoreApp 2.1:


image


После выполнения этого шага можно подвести промежуточный итог: тесты готовы к запуску. Тестовые сборки со всеми зависимостями находятся в директориях:


  • Lib.Tests/bin/Debug/net472
  • Lib.Tests/bin/Debug/netcoreapp2.1/publish

Необходимые для запуска тестов утилиты xunit.console соответственно находятся в:


  • packages/xunit.runner.console/**/net472/xunit.console.exe
  • packages/xunit.runner.console/**/netcoreapp1.0/xunit.console.dll

где ** версия пакета xunit.runner.console.


Вопросы (1) и (2) решены. Для решения вопроса (3) необходимо добавить два шага, выполняющих тесты для двух версии .NET. Потенциально, количество целевых версий тестовых проектов .NET может быть довольно большим, поэтому и шагов тестирования с похожим набором параметров тоже может быть много. Эту проблему можно решить, например, с помощью PowerShell-скрипта или TeamCity Kotlin DSL. С вопросами (4) и (5), в общем случае, приходится разбираться самостоятельно, но, использовав команду .NET, мы получим следующие преимущества:

  • статистику покрытия кода с передачей параметров, кроссплатформенностью и всеми отчетами
  • автоматический запуск xunit.console.dll и _xunit.console.exe _подходящим способом, в зависимости от выбранного окружения (ОС, Docker, и т.д.)

Следующие два шага выполняют тесты командой .NET:

image


image


Открытым остался последний вопрос (6): Как передать результаты тестов TeamCity?. xunit.console делает это самостоятельно, полагаясь на переменную среды окружения _TEAMCITY_PROJECTNAME, которую агент TeamCity автоматически добавляет ко всем порожденным процессам. xunit.console передает результаты тестов, используя TeamCity service messages.


Хотя все вопросы и решены, но было бы здорово, если бы настройка тестов не занимала столько времени и усилий. В какой-то момент энтузиасты попытались уменьшить количество шагов конфигурации, используя механизм TeamCity Meta-Runner.


Meta-Runners Power Pack


Пакет TeamCity мета-ранеров Power Pack содержит мета-ранер xUnit.net-dotCover, который упрощает запуск xUnit-тестов и сбор статистики покрытия кода. Пример конфигурации сборки с его использованием содержит всего два шага:


image


Здесь первый шаг идентичен шагу (2) из предыдущего подхода. Второй шаг, на основе мета-ранера, запускает тесты и выглядит внушительно:


image


Этот шаг получает xunit.console из того же NuGet-пакета xunit.runner.console и запускает тесты сборок только для полных версий .NET Framework (в нашем случае .NET Framework 4.72), попутно собирая статистику покрытия кода. Он заменяет 2 шага скачивания xunit.console и запуска тестов по сравнению с предыдущим подходом.


Недостатки мета-ранера xUnit.net-dotCover:


  • Не может запускать тесты в тестовых проектах, собранных для .NET Core и .NET 5+.
  • Пользовательский интерфейс для передачи параметров dotCover не очень нагляден.
  • Нужно самостоятельно выбирать версию xunit.console в поле Xunit Runner Executable.

Очевидно, что мета-ранер не подходит для нашего случая, но, тем не менее, является рабочим решением для тестовых проектов, нацеленных на полные версии .NET Framework.


dotnet test


.NET Runner с командой test является самым простым, надежным и мощным способом тестировать .NET код в TeamCity. Наша задача решается всего лишь одним простым шагом конфигурации:


image


Такой подход имеет следующие преимущества:


  • Он не зависит от фреймворков тестирования: xUnit, NUint и других. Можно использовать и несколько одновременно.


  • Тесты могут выполняться для всех тестовых сборок решения или нескольких решений, для одного или нескольких проектов.


  • Можно запускать тесты для определенной версии .NET или для набора версий в многоцелевых проектах с использованием элемента TargetFrameworks, включая Full .NET Framework, .NET Core и .NET 5+.


  • Поддерживается тестирование в Docker-контейнерах.


  • Кросс-платформенный сбор статистики покрытия кода.



Если тестовый проект создан в средах разработки Visual Studio или Rider или с использованием шаблонов из командной строки dotnet new, например, dotnet new xunit -o Lib.Tests, то ничего дополнительного делать не нужно. Если же тестовый проект создается в "блокноте", то, помимо зависимости xunit, дополнительно нужно добавить зависимость на пакет Microsoft.NET.Test.Sdk и на тестовый адаптер xunit.runner.visualstudio:


<PackageReference Include="Microsoft.NET.Test.Sdk"/>


<PackageReference Include="xunit.runner.visualstudio"/>


Пакет Microsoft.NET.Test.Sdk содержит набор свойств и скриптов MSBuild, которые делают проект тестовым, а тестовый адаптер отвечает за интеграцию определенного тестового фреймворка: в нашем случае xunit.runner.visualstudio, с Visual Studio Test Platform. Другие фреймворки также имеют свои адаптеры, например, NUnit NUnit3TestAdapter, а MSTest MSTest.TestAdapter.


Мы рекомендуем использовать именно этот подход для тестирования вместе с xUnit и другими тестовыми фреймворками.


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

Подробнее..

Переход с Azure на GCP, с ASP.NET MVC на ASP.NET Core 3.1

26.01.2021 20:09:19 | Автор: admin

Автор: Андрей Жуков, .NET Team Leader, DataArt

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

Задача, поставленная заказчиком: Azure -> GCP

Заказчик решил перейти из одного облака (Azure) в другое (Google Cloud Platform). В некотором отдаленном будущем вообще планировалось перевести серверную часть на Node.js и развивать систему силами команды full-stack typescript-разработчиков. На момент моего входа в проект там существовала пара ASP.NET MVC приложений, которым решили продлить жизнь. Их мне и предстояло перенести в GCP.

Начальная состояние, факторы, мешающие сразу перейти на GCP

Первоначально имелось два ASP.NET MVC-приложения, которые взаимодействовали с одной общей MS SQL базой данных. Они были развернуты на Azure App Services.

Первое приложение назовем его Web Portal имело пользовательский интерфейс, построенный на базе Razor, TypeScript, JavaScript, Knockout и Bootstrap. С этими клиентскими технологиями никаких проблем не предвиделось. Зато серверная часть приложения использовала несколько сервисов, специфичных для Azure: Azure Service Bus, Azure Blobs, Azure Tables storage, Azure Queue storage. С ними предстояло что-то делать, т. к. в GCP ни один из них не поддерживается. Кроме того, приложение использовало Azure Cache for Redis. Для обработки длительных запросов была задействована служба Azure WebJob, задачи которой передавались через Azure Service Bus. По словам программиста, занимавшегося поддержкой, фоновые задачи могли выполняться до получаса.

Изначально архитектура Web Portal в нашем проекте выглядела такИзначально архитектура Web Portal в нашем проекте выглядела так

Azure WebJobs тоже предстояло чем-то заменить. Архитектура с очередью заданий для длительных вычислений не единственное среди возможных решений можно использовать специализированные библиотеки для фоновых задач, например, Hangfire, или обратиться к IHostedService от Microsoft.

Второе приложение назовем его Web API представляло собой ASP.NET WEB API. Оно использовало только MS SQL базы данных. Вернее, в конфигурационном файле были ссылки на несколько баз данных, в реальности же приложение обращалось только к одной их них. Но об этом нюансе мне только предстояло узнать.

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

Итак, нужно было перевести ASP.NET MVC приложения на ASP.NET Core 3.1, перевести WebJob c .NET Framework на .NET Core, чтобы можно было разворачивать их под Linux. Использовать Windows на GCP возможно, но не целесообразно. Надо было избавиться от сервисов, специфичных для Azure, заменить чем-то Azure WebJob, решить, как будем развертывать приложения в GCP, т. е. выбрать альтернативу Azure App Services. Требовалось добавить поддержку Docker. При этом неплохо было бы внести хоть какую-то архитектуру и поправить качество кода.

Общие принципы и соображения

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

В конце каждого этапа приложение должно находиться в стабильном состоянии, т. е. пройти хотя бы Smoke tests.

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

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

При замене сервисов Azure можно либо подобрать альтернативный GCP-сервис, либо выбрать cloud-agnostic-решение. Выбор сервисов в этом проекте и его обоснование в каждом случае мы рассмотрим отдельно.

План работ

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

  1. Web Portal c ASP.NET MVC на ASP.NET Core

    1.1. Анализ кода и зависимостей Web Portal от сервисов Azure и сторонних библиотек, оценка необходимого времени.

    1.2. Перевод Web Portal на .NET Core.

    1.3. Рефакторинг с целью устранения основных проблем.

    1.4. Merge изменений Web Portal из основной ветки репозитория, сделанных параллельно другими разработчиками.

    1.5. Докеризация Web Portal.

    1.6. Тестирование Web Portal, устранение ошибок и развертывание новой версии на Azure.

  2. Web API c ASP.NET MVC на ASP.NET Core

    2.1. Написание E2E автоматических тестов для Web API.

    2.2. Анализ кода и зависимостей Web API от сервисов Azure и сторонних библиотек, оценка необходимого времени.

    2.3. Удаление неиспользуемого исходного кода из Web API.

    2.4. Перевод Web API на .NET Core.

    2.5. Рефакторинг Web API с целью устранения основных проблем.

    2.6. Merge изменений Web API из основной ветки репозитория, сделанных параллельно другими разработчиками.

    2.7. Докеризация Web API.

    2.8. Тестирование Web API, устранение ошибок и развертывание новой версии на Azure.

  3. Устранение зависимостей от Azure

    3.1. Устранение зависимостей Web Portal от Azure.

  4. Развертывание в GCP

    4.1. Развертывание Web Portal в тестовой среде в GCP.

    4.2. Тестирование Web Portal и устранение возможных ошибок.

    4.3. Миграция базы данных для тестовой среды.

    4.4. Развертывание Web API в тестовой среде в GCP.

    4.5. Тестирование Web API и устранение возможных ошибок.

    4.6. Миграция базы данных для prod-среды.

    4.7. Развертывание Web Portal и Web API в prod GCP.

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

.NET Framework -> .NET Core

Перед началом переноса кода я нашел статью о миграции .Net Framework на .Net Core от Microsoft и далее ссылку на миграцию ASP.NET на ASP.NET Core.

С миграцией не-Web-проектов все обстояло относительно просто:

  • преобразование формата хранения NuGet-пакетов с помощью Visual Studio 2019;

  • адаптирование списка этих пакетов и их версий;

  • переход с App.config в XML на settings.json и замена всех имеющихся обращений к конфигурационным значениям на новый синтаксис.

Некоторые версии NuGet-пакетов Azure SDK претерпели изменения, повлекшие несовместимость. В большинстве случаев удалось найти не всегда самую новую, зато поддерживаемую кодом .NET Core версию, которая не требовала бы изменений в логике старого программного кода. Исключением стали пакеты для работы с Azure Service Bus и WebJobs SDK. Пришлось с Azure Service Bus перейти на бинарную сериализацию, а WebJob перевести на новую, обратно несовместимую версию SDK.

C миграцией ASP.NET MVC на ASP.NET Core дело обстояло намного сложнее. Все перечисленные выше действия нужно было проделать и для Web-проектов. Но начинать пришлось с нового ASP.NET Core проекта, куда мы перенесли код старого проекта. Структура ASP.NET Core проекта сильно отличается от предшественника, многие стандартные классы ASP.NET MVC претерпели изменения. Ниже я привожу список того, что изменили мы, и большая его часть будет актуальна для любого перехода с ASP.NET MVC на ASP.NET Core.

  1. Создание нового проекта ASP.NET Core и перенос в него основного кода из старого ASP.NET MVC проекта.

  2. Корректировка зависимостей проекта от внешних библиотек (в нашем случае это были только NuGet-пакеты, соображения по поводу версий библиотек см. выше).

  3. Замена Web.config на appsettings.json и все связанные с этим изменения в коде.

  4. Внедрение стандартного механизма Dependency injection от .NET Core вместо любой его альтернативы, использовавшейся в Asp.NET MVC проекте.

  5. Использование StaticFiles middleware для всех корневых папок статических файлов: изображений, шрифтов, JavaScript-скриптов, CSS-стилей и т. д.

app.UseStaticFiles(); // wwwrootapp.UseStaticFiles(new StaticFileOptions   {     FileProvider = new PhysicalFileProvider(         Path.Combine(Directory.GetCurrentDirectory(), "Scripts")),     RequestPath = "/Scripts"});

Можно перенести все статические файлы в wwwroot.

6. Переход к использованию bundleconfig.json для всех JavaScript и CSS-бандлов вместо старых механизмов. Изменение синтаксиса подключения JavaScript и CSS:

<link rel="stylesheet" href="~/bundles/Content.css" asp-append-version="true" /><script src="~/bundles/modernizr.js" asp-append-version="true"></script>

Чтобы директива asp-append-version="true" работала корректно, бандлы (bundles) должны находиться в корне, т. е. в папке wwwroot (смотри здесь).

Для отладки бандлов я использовал адаптированную версию хелпера отсюда.

7. Изменение механизма обработки UnhadledExceptions: в ASP.NET Core реализована его поддержка, остается с ней разобраться и использовать вместо того, что применялось в проекте раньше.

8. Логирование: я адаптировал старые механизмы логирования для использования стандартных в ASP.NET Core и внедрил Serilog. Последнее опционально, но, по-моему, сделать это стоит для получения гибкого structured logging c огромным количеством вариантов хранения логов.

9. Session если в старом проекте использовалась сессия, то код обращения к ней надо будем немного адаптировать и написать хелпер для сохранения любого объекта, поскольку изначально поддерживается только строка.

10. Routing: в старом проекте использовался механизм, основанный на templates, его надо было чуть-чуть подправить.

11. JSON-сериализация: В ASP.NET Core по умолчанию используется библиотека System.Text.Json вместо Newtonsoft.Json. Microsoft утверждает, что она работает быстрее предшественницы, однако, в отличие от последней, она не поддерживает многое из того, что Newtonsoft.Json умела делать из коробки безо всякого участия программиста. Хорошо, что есть возможность переключиться обратно на Newtonsoft.Json. Именно это я и сделал, когда выяснил, что большая часть сериализации в Web API была сломана, и вернуть ее в рабочее состояние с помощью новой библиотеки, если и возможно, очень непросто. Подробнее об использовании Newtonsoft.Json можно прочитать здесь.

12. В старом проекте использовался Typescript 2.3. С его подключением пришлось повозиться, потребовалось установить Node.js, подобрать правильную версию пакета Microsoft.TypeScript.MSBuild, добавить и настроить tsconfig.json, поправить файл определений (Definitions) для библиотеки Knockout, кое-где добавить директивы //@ts-ignore.

13. Код для принудительной поддержки HTTPS включается автоматически при включении этой опции в визарде проекта. Старый код, использующий пользовательский атрибут HttpsOnly, был при этом убран.

14. Все низкоуровневые действия, такие как получение параметров из body запроса, URL запроса, HTTP Headers и HttpContext потребовали изменений, т. к. API для доступа к ним претерпел изменения по сравнению с ASP.NET MVC. Работы было бы заметно меньше, если бы в старом проекте чаще использовались стандартные binding механизмы через параметры экшенов (Actions) и контроллеров (Controllers).

15. Был добавлен Swagger c помощью библиотеки Swashbuckle.AspNetCore.Swagger.

16. Нестандартный механизм Authentication потребовал рефакторинга для приведения его к стандартному виду.

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

Что делать со специфичными сервисами Azure?

После перехода на ASP.NET Core предстояло избавиться от Azure-сервисов. Можно было либо подобрать решения, которые не зависят от облачной платформы, либо найти что-то подходящее из списка GCP. Благо у многих сервисов есть прямые альтернативы у других облачных провайдеров.

Azure Service Bus мы по настоятельной рекомендации заказчика решили заменить на Redis Pub/Sub. Это достаточно простой инструмент, не настолько мощный и гибкий как, например, RabbitMQ. Но для нашего простого сценария его хватало, а в пользу такого выбора говорило то, что Redis в проекте уже использовался. Время подтвердило решение было правильным. Логика работы с очередью была абстрагирована и выделена в два класса, один из которых реализует отправку произвольного объекта, другой получает сообщения и передает их на обработку. На выделение этих объектов ушло всего несколько часов, а если сам Redis Pub/Sub вдруг потребуется заменить, то и это будет очень просто.

Azure Blobs были заменены на GCP Blobs. Решение очевидное, но все-таки различие в функциональности сервисов нашлось: GCP Blobs не поддерживает добавление данных в конец существующего блоба. В нашем проекте такой блоб использовался для создания подобия логов в формате CSV. На платформе Google мы решили записывать эту информацию в Google Cloud operations suite, ранее известный как Stackdriver.

Хранилище Azure Table Storage использовалось для записи логов приложения и доступа к ним из Web Portal. Для этого существовал логгер, написанный самостоятельно. Мы решили привести этот процесс в соответствие с практиками от Microsoft, т. е. использовать их интерфейс ILogger. Кроме того, была внедрена библиотека для структурного логирования Serilog. В GCP логирование настроили в Stackdriver.

Какое-то время проект должен был параллельно работать и на GCP, и на Azure. Поэтому вся функциональность, зависящая от платформы, была выделена в отдельные классы, реализующие общие интерфейсы: IBlobService, IRequestLogger, ILogReader. Абстрагирование логирования было достигнуто автоматически за счет использования библиотеки Serilog. Но для того, чтобы показывать логи в Web Portal, как это делалось в старом приложении, понадобилось адаптировать порядок записей в Azure Table Storage, реализуя свой Serilog.Sinks.AzureTableStorage.KeyGenerator.IKeyGenerator. В GCP для чтения логов изGoogle Cloud operations были созданы Log Router Sinks, передающие данные в BigQuery, откуда приложение и получало их.

Что делать с Azure WebJobs?

Сервис Azure WebJobs доступен только для Azure App Services on Windows. По сути он представляет собой консольное приложение, использующее специальный Azure WebJobs SDK. Зависимость от этого SDK я убрал. Приложение осталось постоянно работающим консольным и следует похожей логике:

static async Task Main(string[] args){.   var builder = new HostBuilder();  ...              var host = builder.Build();  using (host)  {     await host.RunAsync();  }...}

За всю работу отвечает зарегистрированный с помощью Dependency Injection класс

public class RedisPubSubMessageProcessor : Microsoft.Extensions.Hosting.IHostedService{...public async Task StartAsync(CancellationToken cancellationToken)...public async Task StopAsync(CancellationToken cancellationToken)...}

Это стандартный для .NET Core механизм. Несмотря на отсутствие зависимости от Azure WebJob SDK, это консольное приложение успешно работает как Azure WebJob. Оно также без проблем работает в Linux Docker-контейнере под управлением Kubernetes, о чем речь в статье пойдет позже.

Рефакторинг по дороге

Архитектура и код приложения были далеки от идеала. В ходе многих шагов постепенно производились небольшие изменения кода, который они затрагивали. Были и специально запланированные этапы рефакторинга, согласованные и оцененные вместе с заказчиком. На этих этапах мы устраняли проблемы с аутентификацией и авторизацией, переводили их на практики от Microsoft. Был отдельный этап по внесению некой архитектуры, выделению слоев, устранению ненужных зависимостей. Работа с Web API началась с этапа удаления неиспользуемого кода. При замене многих Azure-сервисов на первом этапе производилось определение интерфейсов, выделение данных зависимостей в отдельные классы.

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

Docker

С поддержкой Docker все сложилось довольно гладко. Dockerfile можно легко добавить с помощью Visual Studio. Я добавил их для всех проектов, соответствующих приложениям, для Web Portal, Web API, WebJob (который в дальнейшем превратился просто в консольное приложение). Эти стандартные Dockerfile от Microsoft не претерпели особенных изменений и заработали из коробки за единственным исключением пришлось в Dockerfile для Web Portal добавить команды для установки Node.js. Этого требует build контейнер для работы с TypeScript.

RUN apt-get update && \apt-get -y install curl gnupg && \curl -sL https://deb.nodesource.com/setup_12.x  | bash - && \apt-get -y install nodejs

Azure App Services -> GKE

Нет единственно правильного решения для развертывания .NET Core-приложений в GCP, вы всегда можете выбрать из нескольких опций:

  • App Engine Flex.

  • Kubernetes Engine.

  • Compute Engine.

В нашем случае я остановился на Google Kubernetes Engine (GKE). Причем к этому моменту у нас уже были контейнеризованные приложения (Linux). GKE, оказалось, пожалуй, наиболее гибким из трех представленных выше решений. Оно позволяет разделять ресурсы кластера между несколькими приложениями, как в нашем случае. В принципе для выбора одного из трех вариантов можно воспользоваться блок-схемой по этой сслыке.

Выше описаны все решения по используемым сервисам GCP, кроме MS SQL Server, который мы заменили на Cloud SQL от Google.

Архитектура нашей системы после миграции в GCPАрхитектура нашей системы после миграции в GCP

Тестирование

Web Portal тестировался вручную, после каждого этапа я сам проводил простенький Smoke-тест. Это было обусловлено наличием пользовательского интерфейса. Если по завершении очередного этапа, новый кусок кода выпускался в Prod, к его тестированию подключались другие пользователи, в частности, Product Owner. Но выделенных QA-специалистов, в проекте, к сожалению, не было. Разумеется, все выявленные ошибки исправлялись до начала очередного этапа. Позднее был добавлен простой Puppeteer-тест, который исполнял сценарий загрузки одного из двух типов отчетов с какими-то параметрами и сравнивал полученный отчет с эталонным. Тест был интегрирован в CICD. Добавить какие-то юнит-тесты было проблематично по причине отсутствия какой-либо архитектуры.

Первым этапом миграции Web API, наоборот, было написание тестов. Для это использовался Postman, затем эти тесты вызывались в CICD с помощью Newman. Еще раньше к старому коду была добавлена интеграция со Swagger, который помог сформировать начальный список адресов методов и попробовать многие из них. Одним из следующих шагов было определение актуального перечня операций. Для этого использовались логи IIS (Internet Information Services), которые были доступны за полтора месяца. Для многих актуальных методов перечня было создано несколько тестов с разными параметрами. Тесты, приводящие к изменению данных в базе, были выделены в отдельную Postman-коллекцию и не запускались на общих средах выполнения. Разумеется, все это было параметризовано, чтобы можно было запускать и на Staging, и на Prod, и на Dev.

Тестирование позволило нам убедиться, что продукт после миграции сохранил стабильность. Конечно, идеальным вариантов было бы покрыть всю функциональность автоматизированными тестами. Поэтому в случае с Web API несмотря на гораздо большие усилия, затраченные в самом начале, миграция, поиск и исправление ошибок затем проходили гораздо легче.

Azure MS SQL -> GCP Managed MS SQL

Миграция MS SQL из Managed Azure в GCP Cloud SQL оказалась не такой простой задачей, как представлялось вначале. Основных причин тому оказался несколько:

  • Очень большой размер базы данных (Azure портал показал: Database data storage /

    Used space 181GB).

  • Наличие зависимостей от внешних таблиц.

  • Отсутствие общего формата для экспорта из Azure и импорта в GCP Cloud SQL.

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

Перед началом миграции нужно удалить все ссылки на внешние таблицы и базы данных, иначе миграция будет неудачной. Azure SQL поддерживает экспорт только в формат bacpac, более компактный по сравнению со стандартным backup форматом. В нашем случае вышло 6 Гб в bacpac против 154 Гб в backup. Но GCP Cloud позволят импортировать только backup, поэтому нам потребовалась конвертация, сделать которую удалось лишь посредством восстановления в локальную MS SQL из bacpac и создания backup уже из нее. Для этих операций потребовалось установить последнюю версию Microsoft SQL Server Management Studio, причем локальный сервер MS SQL Server был версией ниже. Немало операций заняли по многу часов, некоторые и вовсе длились по несколько дней. Рекомендую увеличить квоту Azure SQL перед импортом и сделать копию prod базы, чтобы импортировать из нее. Где-то нам потребовалось передавать файл между облаками, чтобы ускорить загрузку на локальную машину. Мы также добавили SSD-диск на 1 Тб специально под файлы базы данных.

Задачи на будущее

При переходе с Azure App Services на GCP Kubernetes мы потеряли CICD, Feature Branch deployments, Blue/Green deployment. На Kubernetes все это несколько сложнее и требует иной реализации, но наверняка делается посредством все тех же Github Actions. В новом облаке следуем концепции Iac (Infrastructure-as-Code) вместе с Pulumi.

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru