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

Asp.net core

Перевод Что из себя представляет класс Startup и Program.cs в ASP.NET Core

15.02.2021 16:13:26 | Автор: admin

В преддверии старта курса C# ASP.NET Core разработчик подготовили традиционный перевод полезного материала.

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


Введение

Program.cs это место, с которого начинается приложение. Файл Program.cs в ASP.NET Core работает так же, как файл Program.cs в традиционном консольном приложении .NET Framework. Файл Program.cs является точкой входа в приложение и отвечает за регистрацию и заполнение Startup.cs, IISIntegration и создания хоста с помощью инстанса IWebHostBuilder, метода Main.

Global.asax больше не входит в состав приложения ASP.NET Core. В ASP.NET Core заменой файла Global.asax является файл Startup.cs.

Файл Startup.cs это также точка входа, и он будет вызываться после выполнения файла Program.cs на уровне приложения. Он обрабатывает конвейер запросов. Класс Startup запускается в момент запуска приложения.

Описание

Что такое Program.cs?

Program.cs это место, с которого начинается приложение. Файл класса Program.cs является точкой входа в наше приложение и создает инстанс IWebHost, на котором размещается веб-приложение.

public class Program {      public static void Main(string[] args) {          BuildWebHost(args).Run();      }      public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args).UseStartup < startup > ().Build();  }  

WebHost используется для создания инстансов IWebHost, IWebHostBuilder и IWebHostBuilder, которые имеют предварительно настроенные параметры. Метод CreateDefaultBuilder() создает новый инстанс WebHostBuilder.

Метод UseStartup() определяет класс Startup, который будет использоваться веб-хостом. Мы также можем указать наш собственный пользовательский класс вместо Startup.

Метод Build() возвращает экземпляр IWebHost, а Run() запускает веб-приложение до его полной остановки.

Program.cs в ASP.NET Core упрощает настройку веб-хоста.

public static IWebHostBuilder CreateDefaultBuilder(string[] args) {      var builder = new WebHostBuilder().UseKestrel().UseContentRoot(Directory.GetCurrentDirectory()).ConfigureAppConfiguration((hostingContext, config) => {          /* setup config */ }).ConfigureLogging((hostingContext, logging) => {          /* setup logging */ }).UseIISIntegration()      return builder;  }

Метод UseKestrel() является расширением, которое определяет Kestrel как внутренний веб-сервер. Kestrel это кроссплатформенный веб-сервер для ASP.NET Core с открытым исходным кодом. Приложение работает с модулем Asp.Net Core, и необходимо включить интеграцию IIS (UseIISIntegration()), которая настраивает базовый адрес и порт приложения.

Он также настраивает UseIISIntegration(), UseContentRoot(), UseEnvironment(Development), UseStartup() и другие доступные конфигурации, например Appsetting.json и Environment Variable. UseContentRoot используется для обозначения текущего пути к каталогу.

Мы также можем зарегистрировать логирование и установить минимальный loglevel, как показано ниже. Это также переопределитloglevel, настроенный в файле appsetting.json.

.ConfigureLogging(logging => { logging.SetMinimumLevel(LogLevel.Warning); }) 

Таким же образом мы можем контролировать размер тела нашего запроса и ответа, включив эту опцию файле program.cs, как показано ниже.

.ConfigureKestrel((context, options) => { options.Limits.MaxRequestBodySize = 20000000; });  

ASP.net Core является кроссплатформенным и имеет открытый исходный код, а также его можно размещать на любом сервере (а не только на IIS, внешнем веб-сервере), таком как IIS, Apache, Nginx и т. д.

Что такое файл Startup?

Обязателен ли файл startup.cs или нет? Да, startup.cs является обязательным, его можно специализировать любым модификатором доступа, например, public, private, internal. В одном приложении допускается использование нескольких классов Startup. ASP.NET Core выберет соответствующий класс в зависимости от среды.

Если существует класс Startup{EnvironmentName}, этот класс будет вызываться для этого EnvironmentName или будет выполнен файл Startup для конкретной среды в целом (Environment Specific), чтобы избежать частых изменений кода/настроек/конфигурации в зависимости от среды.

ASP.NET Core поддерживает несколько переменных среды, таких как Development, Production и Staging. Он считывает переменную среды ASPNETCORE_ENVIRONMENT при запуске приложения и сохраняет значение в интерфейсе среды хоста (into Hosting Environment interface).

Обязательно ли этот класс должен называться startup.cs? Нет, имя класса не обязательно должно быть Startup.

Мы можем определить два метода в Startup файле, например ConfigureServices и Configure, вместе с конструктором.

Пример Startup файла

public class Startup {      // Use this method to add services to the container.      public void ConfigureServices(IServiceCollection services) {          ...      }      // Use this method to configure the HTTP request pipeline.      public void Configure(IApplicationBuilder app) {          ...      }  }  

Метод ConfigureServices

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

Метод ConfigureServices включает параметр IServiceCollection для регистрации сервисов. Этот метод должен быть объявлен с модификатором доступа public, чтобы среда могла читать контент из метаданных.

public void ConfigureServices(IServiceCollection services)  {     services.AddMvc();  }  

Метод Configure

Метод Configure используется для указания того, как приложение будет отвечать на каждый HTTP-запрос. Этот метод в основном используется для регистрации промежуточного программного обеспечения (middleware) в HTTP-конвейере. Этот метод принимает параметр IApplicationBuilder вместе с некоторыми другими сервисами, такими как IHostingEnvironment и ILoggerFactory. Как только мы добавим какой-либо сервис в метод ConfigureService, он будет доступен для использования в методе Configure.

public void Configure(IApplicationBuilder app)  {     app.UseMvc();  }

В приведенном выше примере показано, как включить функцию MVC в нашем фреймворке. Нам нужно зарегистрировать UseMvc() в Configure и сервис AddMvc() в ConfigureServices. UseMvc является промежуточным программным обеспечением. Промежуточное программное обеспечение (Middleware) это новая концепция, представленная в Asp.net Core. Для вас доступно множество встроенных промежуточных программ, некоторые из которых указаны ниже.

app.UseHttpsRedirection();  app.UseStaticFiles();  app.UseRouting();  app.UseAuthorization();  UseCookiePolicy();  UseSession(); 

Промежуточное программное обеспечение можно настроить в http-конвейере с помощью команд Use, Run и Map.

Run

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

Use

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

Map

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

app.Map("/MyDelegate", MyDelegate);

Чтобы получить более подробную информацию о промежуточном программном обеспечении, переходите сюда

ASP.net Core имеет встроенную поддержку внедрения зависимостей (Dependency Injection). Мы можем настроить сервисы для контейнера внедрения зависимостей, используя этот метод. Следующие способы конфигурационные методы в Startup классе.

AddTransient

Transient (временные) объекты всегда разные; каждому контроллеру и сервису предоставляется новый инстанс.

Scoped

Scoped используются одни и те же объекты в пределах одного запроса, но разные в разных запросах.

Singleton

Singleton объекты одни и те же для каждого объекта и каждого запроса.

Можем ли мы удалить startup.cs и объединить все в один класс с Program.cs?

Ответ: Да, мы можем объединить все классы запуска в один файл.

Заключение

Вы получили базовое понимание того, почему файлы program.cs и startup.cs важны для нашего приложения Asp.net Core и как их можно настроить. Мы также немного познакомились с переменной среды (Environment Variable), внедрение зависимостей, промежуточным программным обеспечением и как его настроить. Кроме того, мы увидели, как можно настроить UseIIsintegration() и UseKestrel().


Узнать подробнее о курсе C# ASP.NET Core разработчик.

Смотреть открытый вебинар по теме Отличия структурных шаблонов проектирования на примерах.

Подробнее..

Учим ASP.NET Core новым трюкам на примере Json Rpc 2.0

06.04.2021 04:10:16 | Автор: admin

Хотите добиться нестандартного поведения от aspnet core? Мне вот понадобилось добавить прозрачную поддержку Json Rpc. Расскажу о том, как я искал решения для всех хотелок, чтобы вышло красиво и удобно. Может быть, вам пригодятся знания о разных точках расширения фреймворка. Или о тонкостях поддержки Json Rpc, даже на другом стеке/языке.


В результате получилась библиотека, которая позволяет работать с Json Rpc, вообще не задумываясь, что он спрятан под капотом. При этом пользователю не нужно уметь ничего нового, только привычный aspnet mvc.


Введение


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


В тексте под Aspnet подразумевается ASP.Net Core MVC, в частности все писалось на 2.2, с прицелом на то, что выйдет 5.x и допилим под него.

И Json Rpc протокол JSON RPC 2.0 поверх HTTP.

Еще для чтения стоит ознакомиться с терминами протокола: method, params, request, notification...


Зачем все это?


Я .NET техлид в банке Точка и работаю над инфраструктурой для шарповых сервисов. Стараюсь сделать так, чтобы разработчикам было удобно, а бизнесу быстро и без ошибок. Добрался до причесывания обменов под корпоративные стандарты, и тут началось...


У нас для синхронного общения по HTTP принят Json Rpc 2.0. А у шарпистов основной фреймворк ASP.NET Core MVC, и он заточен под REST. И на нем уже написано некоторое количество сервисов. Если немного абстрагироваться, то REST, JSON RPC, и любой RPC вообще об одном и том же: мы хотим, чтобы на удаленной стороне что-то произошло, передаем параметры, ожидаем результат. А еще транспорт совпадает, все по HTTP. Почему бы не воспользоваться привычным aspnet для работы с новым протоколом? Хочется при этом поддержать стандарт полностью: в компании много разных стеков, и у всех Json Rpc клиенты работают немного по-разному. Будет неприятно нарваться на ситуацию, когда запросы например от питонистов не заходят, и нужно что-то костылить.


Со стороны aspnet-а можно делать типовые вещи разными способами, лишь бы разработчику было удобно. Довольно много ресурсов командой уже потрачено на то, чтобы разобраться, какой из способов больше нам подходит. А ведь еще нужно поддерживать единообразие. Чтобы никто не сходил с ума, читая код сервиса, который написан коллегой полгода назад. То есть вы нарабатываете best practices, поддерживаете какие-то небольшие библиотечки вокруг этого, избавляетесь от бойлерплейта. Не хочется это терять.


Еще немаловажный момент: желательно, чтобы опыт у разработчиков не терял актуальность, то есть не затачивался на внутренние костыли и самописные фреймворки. Если ты три года пишешь веб-сервисы на шарпе, претендуешь на мидлосеньора, а потом не можешь сделать, например, авторизацию общепринятыми способами в пустом проекте, потому что у вас было принято писать в коде контроллера if(cookie.Contains(userName)) это беда.


Конечно, протокол уже реализован на C#, и не раз. Ищем готовые библиотеки. Выясняется, что они либо тащат свои концепции, то есть придется долго вникать, как это готовить. Либо делают почти то, что нужно, но тяжело кастомизируются и переизобретают то, что в aspnet уже есть.

Собираем хотелки и пишем код


Чего хочется добиться? Чтобы как обычно писать контроллеры, накидывать фильтры и мидлвари, разбирать запрос на параметры. Чтобы наработанные best practices и библиотеки для aspnet подходили as-is. И при этом не мешать работать существующему MVC коду. Так давайте научимся обрабатывать Json Rpc теми средствами, что нам предоставляет фреймворк!


Request Routing


Казалось бы, HTTP уже есть, и нам от него надо только обрабатывать POST и возвращать всегда 200 OK. Контент всегда в JSON. Все прекрасно, сейчас напишем middleware и заживем.
Но не тут-то было! Мидлварь написать можно, только потом будем получать все запросы в один action, а в нем придется switch(request.Method) или запускать хендлеры из DI каким-нибудь костылем. А кто будет авторизацию и прочие фильтры прогонять в зависимости от метода? Переизобретать все это заново ящик Пандоры: делаешь свой аналог пайплайна, а потом придется поддерживать общий код и для aspnet, и для своего пайплайна. Ну, чтобы не было внезапных различий между тем, как вы проверяете роли или наличие какого-то заголовка.
Значит, придется влезть в роутинг и заставить его выбирать controller и action, глядя на тело HTTP запроса, а не только на url.


ActionMethodSelectorAttribute


К сожалению, все часто используемые инструменты для роутинга не позволяют парсить запрос или выполнять произвольный код. Есть стандартный роутер, но его нельзя без лишних проблем расширить или что-то в нем перегрузить. IRouter целиком писать, конечно же, не надо, если мы хотим гарантировать что обычный MVC не сломается. Казалось бы, есть Endpoint Routing, но в 2.2 сделана его начальная и неполная реализация, и чего-то полезного с ним напрямую не сделаешь. Костыли типа переписывания еndpoint на свой (после того, как Endpoint Routing отработал) почему-то не взлетели. Можно, конечно, прямо в middleware сделать редирект на нужный url, и это будет работать, только url испортится.


После долгих поисков был найден ActionMethodSelectorAttribute, который делает как раз то, что нужно: позволяет вернуть true/false в момент выбора controller и action! У него есть контекст с именем текущего метода-кандидата и его контроллера. Очень удобно.


Остается две проблемы: как повесить атрибут на все нужные нам контроллеры, и как не парсить тело каждый раз, когда выполняется проверка на "пригодность" метода?


Conventions


Атрибуты на контроллеры можно расставлять кодогенерацией, но это слишком сложно. У фреймворка и на этот случай есть решение: IControllerModelConvention, IActionModelConvention. Это что-то вроде знакомых многим Startup Filters: запускаются один раз на старте приложения и позволяют сделать все что угодно с метаданными контроллеров и методов, например переопределить роутинг, атрибуты, фильтры.


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


public abstract class JsonRpcController : ControllerBase {}

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


полезный код в ControllerConvention
public void Apply(ControllerModel controllerModel){    if (!typeof(JsonRpcController).IsAssignableFrom(controllerModel.ControllerType))    {        return;    }    controllerModel.Selectors.Clear();    controllerModel.Filters.Insert(0, new ServiceFilterAttribute(typeof(JsonRpcFilter)));}

Selectors отвечают за роутинг, и я честно не смог найти, почему это коллекция. В любом случае, нам не нужен стандартный роутинг по правилам MVC, поэтому удаляем все, что есть. Забегая вперед, применяем JsonRpcFilter, который будет отвечать за оборачивание ActionResult.


А вот ActionConvention
public void Apply(ActionModel actionModel){    if (!typeof(JsonRpcController).IsAssignableFrom(actionModel.Controller.ControllerType))    {        return;    }    actionModel.Selectors.Clear();    actionModel.Selectors.Add(new SelectorModel()    {        AttributeRouteModel = new AttributeRouteModel() {Template = "/api/jsonrpc"},        ActionConstraints = {new JsonRpcAttribute()}    });}

Здесь на каждый метод в наших контроллерах мы повесим один и тот же route, который потом вынесем в настройки.


И добавим тот самый атрибут
class JsonRpcAttribute : ActionMethodSelectorAttribute{    public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action)    {        var request = GetRequest();  // пока не понятно как        // return true если action подходит под запрос, например:        return request.Method == action.DisplayName;    }}

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


Middleware


Пишем middleware, которая будет проверять, что запрос похож на Json Rpc: правильный Content-Type, обязательно POST, и тело содержит подходящий JSON. Запрос можно десериализовать в объект и сложить в HttpContext.Items. После этого его можно будет достать в любой момент.


Есть одна загвоздка: у middleware еще нет информации о типе, в который нужно десериализовать params, поэтому мы пока оставим их в виде JToken.


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


Parameter Binding


Мы научились подбирать контроллер и метод под запрос. Теперь нужно что-то делать с аргументами. Можно достать то, что десериализовала middleware из HttpContext.Items, и десериализовать JToken вручную в нужный тип, но это бойлерплейт и ухудшение читаемости методов. Можно взять JSON целиком из тела запроса с помощью [FromBody], но тогда всегда будет присутствовать шапка протокола: id, версия, метод. Придется каждую модель оборачивать этой шапкой: Request<MyModel> или class MyModel: RequestBase, и снова получим бойлерплейт.


Эти решения были бы еще терпимы, если бы протокол не вставлял палок в колеса.


Разные params


Json Rpc считает, что параметры, переданные массивом [] это одно и то же, что и параметры, переданные объектом {}! То есть, если нам прислали массив, нужно подставлять их в свой метод по порядку. А если прислали объект, то разбирать их по именам. Но вообще, оба сценария должны работать для одного и того же метода. Например, вот такие params эквивалентны и должны одинаково биндиться:


{"flag": true, "data": "value", "user_id": 1}[1, "value", true]

public void DoSomething(int userId, string data, bool flag)

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


Реализация


Посмотрим, что нам доступно для управления биндингом. Есть IModelBinder и IModelBinderProvider, но они смотрят на тип объекта. Заранее мы не знаем, какой тип пользователь захочет биндить. Может быть, int или DateTime. Мы не хотим конфликтовать с aspnet, поэтому просто добавить свой биндер для всех типов нельзя. Есть IValueProvider, но он возвращает только строки. Наконец, есть атрибуты FromBody, FromQuery и так далее. Смотрим в реализацию, находим интерфейс IBinderTypeProviderMetadata. Он нужен, чтобы возвращать нужный binder для параметра. Как раз то, что нужно!


Пишем свой FromParamsAttribute
 [AttributeUsage(AttributeTargets.Parameter)]public class FromParamsAttribute : Attribute, IBindingSourceMetadata, IBinderTypeProviderMetadata{    public BindingSource BindingSource => BindingSource.Custom;    public Type BinderType => typeof(JsonRpcModelBinder);}

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


`BindingInfo` с той же информацией
public void Apply(ParameterModel parameterModel){    if (!typeof(JsonRpcController).IsAssignableFrom(parameterModel.Action.Controller.ControllerType))    {        return;    }    if (parameterModel.BindingInfo == null)    {        parameterModel.BindingInfo = new BindingInfo()        {            BinderType = typeof(JsonRpcModelBinder),            BindingSource = BindingSource.Custom        };    }}

Проверка на BindingInfo == null позволяет использовать другие атрибуты, если нужно. То есть можно смешивать FromParams и штатные FromQuery, FromServices. Ну а по умолчанию, если ничего не указано, convention применит BindingInfo, аналогичный FromParams.


Удобства


Стоит учесть сценарий, когда неудобно разбирать params на отдельные аргументы. Что, если клиент просто прислал свой объект "как есть", а в нем очень много полей? Нужно уметь биндить params целиком в один объект:


{"flag": true, "data": "value", "user_id": 1}

public void DoSomething(MyModel model)

Но что делать, если придет json-массив? Теоретически, можно бы узнать порядок properties в объекте, и биндить по порядку. Но из рефлексии этого не достать, такая информация просто не сохраняется. Поэтому массив в объект сбиндить не получится без костылей типа атрибутов с номерами Но можно сделать проще: сделаем эту фичу опциональной. Да, она не работает с массивами, что ломает поддержку протокола, поэтому придется выбирать. Добавим параметр в наш атрибут:


BindingStyle
public enum BindingStyle { Default, Object, Array }...public FromParamsAttribute(BindingStyle bindingStyle){    BindingStyle = bindingStyle;}

Default поведение по умолчанию, когда содержимое params биндится в аргументы. Object когда пришел json-объект, и мы биндим его в один параметр целиком. Array когда пришел json-массив и мы биндим его в коллекцию. Например:


// это успешно сбиндится: {"flag": true, "data": "value", "user_id": 1}// а это будет ошибкой: [1, "value", true]public void DoSomething1([FromParams(BindingStyle.Object)] MyModel model)// это успешно сбиндится: [1, "value", true]// а это будет ошибкой: {"flag": true, "data": "value", "user_id": 1}public void DoSomething2([FromParams(BindingStyle.Array)] List<object> data)

Всю эту логику придется реализовать в JsonRpcModelBinder. Приводить здесь код нет смысла: его много, но он тривиальный. Разве что упомяну несколько интересных моментов:


Как сопоставить имя аргумента и ключ в json-объекте?


JsonSerizlizer не позволяет "в лоб" десериализовать ключ json объекта как шарповое имя property или аргумента. Зато позволяет сериализовать имя в ключ.


// вот так не получится{"user_id": 1} => int userId//  зато можно наоборот и запомнить это в метаданныхint userId => "user_id"

То есть нужно для каждого аргумента узнать его "json-имя" и сохранить в метаданных. У нас уже есть conventions, там и допишем нужный код.


Учимся у aspnet


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


Регистрация в DI-контейнере


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


резолвить зависимости вручную
public Task BindModelAsync(ModelBindingContext context){    var service = context.HttpContext.RequestServices.GetServices<IService>();    // ...}

Error handling


Все ошибки протокол предлагает возвращать в виде специального ответа, в котором могут быть любые детали. Еще там описаны некоторые крайние случаи и ошибки для них. Придется перехватывать все exception-ы, заворачивать их в Json Rpc ответ, уметь прятать stack trace в зависимости от настроек (мы же не хотим высыпать все подробности на проде?). А еще нужно дать пользователю возможность вернуть свою Json Rpc ошибку, вдруг у кого-то на этом логика построена. В общем, ошибки придется перехватывать на разных уровнях. После написания десятого try/catch внутри catch поневоле начинаешь задумываться, что неплохо бы иметь возможность писать код с гарантией отсутствия exception-ов, или хотя бы с проверкой, что ты перехватил все, что можно...


Action вернул плохой ActionResult или Json Rpc ошибку


Возьмем IActionFilter.OnResultExecuting и будем проверять, что вернулось из метода: нормальный объект завернем в Json Rpc ответ, плохой ответ, например 404, завернем в Json Rpc ошибку. Ну или метод уже вернул ошибку по протоколу.


Binding failed


Нам пригодится IAlwaysRunResultFilter.OnActionExecuting: можно проверить context.ModelState.IsValid и понять, что биндинг упал. В таком случае вернем ошибку с сообщением, что не получилось у биндера. Если ничего не делать, то в action попадут кривые данные, и придется проверять каждый параметр на null или default.


Схожим образом работает стандартный ApiControllerAttribute: он возвращает 400, если биндинг не справился.


Что-то сломалось в pipeline


Если action или что-нибудь в pipeline выбросит exception, или решит записать HttpResponse, то единственное место, где мы еще можем что-то сделать с ответом, это middleware. Придется и там проверять, что получилось после обработки запроса: если HTTP статус не 200 или тело не подходит под протокол, придется заворачивать это в ошибку. Кстати, если писать ответ прямо в HttpResponse.Body, то сделать с ним уже ничего не получится, но для этого тоже будет решение чуть ниже.


Ошибки это сложно


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


class JsonRpcErrorFactory{    IError NotFound(object errorData){...}    IError InvalidRequest(object errorData){...}    IError Error(int code, string message, object errorData){...}    IError Exception(Exception e){...}    // и так далее}

Batch


Batch-запросы aspnet не поддерживает никак. А они требуются стандартом. И хочется, чтобы на каждый запрос из батча был свой пайплайн, чтобы в тех же фильтрах не городить огород. Можно, конечно, сделать прокси, который будет разбирать батч на отдельные запросы, отправлять их на localhost, потом собирать ответ. Но это кажется безумным оверхедом из-за сериализации HTTP body в байты, установления соединения После долгих путешествий по issues в Github, находим грустный тред о том, что батчи когда-то были, но пока нет и неизвестно когда вернутся. А еще они есть в OData, но это целый отдельный мир, фреймворк поверх фреймворка погодите-ка, мы же тоже пишем что-то такое!. Там же находим идею и репозиторий с реализацией: можно скопировать HttpContext и в middleware позвать next() со своим контекстом, а потом собрать результат и отправить все вместе уже в настоящий HttpContext. Это поначалу немного ломает мозг, потому что мы привыкли к мантре: нужно передавать управление вызовом next(context), и по-другому никто эту штуку не использует.


Таким образом, middleware будет парсить Json Rpc запрос, создавать копию контекста, и вызывать пайплайн дальше. Это же пригодится для перехвата ошибок, если кто-то решит писать прямо в HttpResponse.Body: мы вместо настоящего body подсунем MemoryStream и проверим, что там валидный JSON.


У этого подхода есть минус: мы ломаем стриминг для больших запросов/ответов. Но что поделать, JSON не подразумевает потоковую обработку. Для этого, конечно, есть разные решения, но они гораздо менее удобны, чем Json.NET.


ID


Протокол требует поле id в запросе, при чем там могут быть число, строка или null. В ответе должен содержаться такой же id. Чтобы одно и то же поле десериализовалось как число или строка, пришлось написать классы-обертки, интерфейс IRpcId и JsonConverter, который проверяет тип поля и десериализует в соответствующий класс. В момент, когда мы сериализуем ответ, из HttpContext.Items достаем IRpcId и прописываем его JToken-значение. Таким образом, пользователю не надо самому заморачиваться с проставлением id и нет возможности забыть об этом. А если нужно значение id, можно достать из контекста.


Notification


Если id отсутствует, то это не запрос, а уведомление (notification). На notification не должен уходить ответ: ни успешный, ни с ошибкой, вообще никакой. Ну, то есть по HTTP-то мы вернем 200, но без тела. Чтобы все работало одинаково для запросов и нотификаций, пришлось выделить абстракцию над ними, и в некоторых местах проверять, запрос ли это и нужно ли сериализовать ответ.


Сериализация


Aspnet умеет сериализовать JSON. Только у него свои настройки, а у нас свои. Сериализация настраивается с помощью Formatters, но они смотрят только на Content-Type. У Json Rpc он совпадает с обычным JSON, поэтому просто так добавить свой форматтер нельзя. Вложенные форматтеры или своя реализация плохая идея из-за сложности.


Решение оказалось простым: мы уже оборачиваем ActionResult в фильтре, там же можно


подставить нужный форматтер
...var result = new ObjectResult(response){    StatusCode = 200,};result.Formatters.Add(JsonRpcFormatter);result.ContentTypes.Add(JsonRpcConstants.ContentType);...

Здесь JsonRpcFormatter это наследник JsonOutputFormatter, которому переданы нужные настройки.


Configuration


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


Имя метода


У Json Rpc запросов есть поле method, которым определяется, что должно быть вызвано на сервере. И это поле просто строка. Ей пользуются как угодно. Придется научить сервер понимать распространенные варианты.


public enum MethodStyle {ControllerAndAction, ActionOnly}

ControllerAndAction будет интерпретировать method как class_name.method_name.


ActionOnly просто method_name.


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


Сериализация


Еще встает вопрос с JSON-сериализацией. Формат "шапки" строго обозначен в протоколе, то есть переопределять его нужно примерно никогда. А вот формат полей params, result и error.data оставлен свободным. Пользователь может захотеть сериализацию со своими особыми настройками. Нужно дать такую возможность, при этом не позволяя сломать сериализацию шапки и не накладывая особых требований на пользователя. Например, для работы с шапкой используются хитрые JsonConverterы, и не хотелось бы чтобы они как-то торчали наружу. Для этого сделана минимальная обертка поверх JsonSeralizer, чтобы пользователь мог зарегистрировать в DI свой вариант и не сломать REST/MVC.


Нестандартные ответы


Бывает, что нужно вернуть бинарный файл по HTTP, или Redirect для авторизации. Это явно идет в разрез с Json Rpc, но очень удобно. Такое поведение нужно разрешать при необходимости.


Объединяем все вместе


Добавим классы с опциями, чтобы рулить умолчаниями
public class JsonRpcOptions{    public bool AllowRawResponses { get; set; }  // разрешить ответы не по протоколу?    public bool DetailedResponseExceptions { get; set; }  // маскировать StackTrace у ошибок?    public JsonRpcMethodOptions DefaultMethodOptions { get; set; }  // см. ниже    public BatchHandling BatchHandling { get; set; }  // задел на параллельную обработку батчей в будущем}public class JsonRpcMethodOptions{        public Type RequestSerializer { get; set; }  // пользовательский сериалайзер    public PathString Route { get; set; }  // маршрут по умолчанию, например /api/jsonrpc    public MethodStyle MethodStyle { get; set; }  // см. выше}

И атрибуты, чтобы умолчания переопределять:


  • FromParams про который было выше
  • JsonRpcMethodStyle чтобы переопределить MethodStyle
  • JsonRpcSerializerAttribute чтобы использовать другой сериалайзер.

Для роутинга свой атрибут не нужен, все будет работать со стандартным [Route].


Подключаем


Пример кода, который использует разные фичи. Важно заметить, что это никак не мешает обычному коду на aspnet!


Startup.cs
services.AddMvc()    .AddJsonRpcServer()    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);// или с опциямиservices.AddMvc()    .AddJsonRpcServer(options =>    {        options.DefaultMethodOptions.Route = "/rpc";        options.AllowRawResponses = true;    })    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

Контроллер
public class MyController : JsonRpcController{    public ObjectResult Foo(object value, bool flag)    {        return Ok(flag ? value : null);    }    public void BindObject([FromParams(BindingStyle.Object)] MyModel model)    {    }    [Route("/test")]    public string Test()    {        return "test";    }    [JsonRpcMethodStyle(MethodStyle.ActionOnly)]    public void SpecialAction()    {    }    [JsonRpcSerializer(typeof(CamelCaseJsonRpcSerializer))]    public void CamelCaseAction(int myParam)    {    }}

Клиент


Конечно, для полного удобства нужен клиент. Пришлось разбить библиотеку на три сборки: сервер, клиент и общий код (модели и json-конвертеры).


HttpClient


В .net core HttpClient научили работать с DI, типизировать и вообще все стало гораздо удобнее. Грех не воспользоваться!


Batch


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


Обработка ошибок


Снова сложности с обработкой ошибок. Дело в том, что мы не знаем, с какими правилами сериализации сервер вернул ошибку: со стандартными, как в "шапке", или с кастомными, например когда мы договорились на клиенте и на сервере использовать camelCase, но у сервера что-то сломалось в middleware и дело до нашего action не дошло вообще. Поэтому придется пробовать десериализовать и так, и так. Здесь нет очевидно хорошего решения, поэтому интерфейс response содержит


Разные методы для интерпретации ответа
T GetResponseOrThrow<T>();  // достать успешный ответ, если нет - достать ошибку и выбросить ее как исключениеT AsResponse<T>(); // только достать ответError<JToken> AsAnyError(); // достать ошибку, не десериализуя ееError<T> AsTypedError<T>(); // достать ошибку по правилам сериализации как в запросе или по дефолтным, если не получилосьError<ExceptionInfo> AsErrorWithExceptionInfo(); // достать ошибку с деталями exception-а с сервера

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


Developer Experience


Я считаю, что получилось решение, когда разработчик может подключить и забыть, что там какой-то Json Rpc. При этом можно полагаться на свой опыт работы с aspnet, использовать привычные подходы и без проблем подключать любые сторонние библиотеки к приложению. С другой стороны, есть возможность переопределять какое-то поведение, мало ли какие потребности возникнут. Часто используемые штуки вынесены в параметры и атрибуты, а если не хватает, можно посмотреть код и подменить что-то: все методы виртуальные, сервисы используются по интерфейсам. Можно расширить или написать полностью свою реализацию.


TODO


В планах: добавить поддержку aspnetcore 5.x, добить покрытие тестами до 100%, перевести документацию на русский и добавить параллельную обработку батчей. Ну и, конечно же, поддерживать проект как нормальный open source: любой фидбек, пуллреквесты и issues приветствуются!


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


Ссылки


Исходники


Документация


Бонус


Статья лежала в черновиках почти год, за это время к библиотеке была добавлена поддержка автодокументации Swagger и OpenRpc. А еще сейчас в разработке поддержка OpenTelemetry. Кому-нибудь интересны подробности? Там местами такая жуть...

Подробнее..

.NET Core Взаимодействие микросервисов через Web api

19.06.2020 12:20:11 | Автор: admin

Введение



Практически все, кто имел дело с микросервисами в .NET Core, наверняка знают книгу Кристиана Хорсдала Микросервисы на платформе .NET. Здесь прекрасно описаны подходы к построению приложения на основе микросервисов, подробно рассмотрены вопросы мониторинга, журналирования, управления доступом. Единственное, чего не хватает это инструмента автоматизации взаимодействия между микросервисами.

При обычном подходе при разработке микросервиса параллельно разрабатывается web-клиент для него. И каждый раз, когда меняется web-интерфейс микросервиса, приходится затрачивать дополнительные усилия для соответствующих изменений web-клиента. Идея генерировать пару web-api/web-клиент с использованием OpenNET тоже достаточно трудоемка, хотелось бы чего-то более прозрачного для разработчика.

Итак, при альтернативном подходе в разработке нашего приложения хотелось бы:
Структуру микросервиса описывать интерфейсом с использованием атрибутов, описывающих тип метода, маршрут и способ передачи параметров, как это делается в MVC.
Функциональность микросервиса разрабатывается исключительно в классе,
реализующим этот интерфейс. Публикация конечных точек микросервиса должна быть автоматической, не требующей сложных настроек.
Web-клиент для микросервиса должен генерироваться автоматически на основе интерфейса и предоставляться через Dependency Injection.
Должен быть механизм организации редиректов на конечные точки микросервисов из главного приложения, взаимодействующего с пользовательским интерфейсом.

В соответствии с этим критериями разработан Nuget-пакет Shed.CoreKit.WebApi. В дополнение к нему создан вспомогательный пакет Shed.CoreKit.WebApi.Abstractions, содержащий атрибуты и классы, которые могут быть использованы при разработке общих проектов-сборок, где не требуется функциональность основного пакета.

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

Здесь и далее мы будем использовать следующую терминологию:
Микросервис приложение (проект) ASP.NET Core, которое может запускаться консольно, под Internet Information Services (IIS) или в Docker-контейнере.
Интерфейс сущность .NET, набор методов и свойств без реализации.
Конечная точка путь к корню приложения микросервиса или реализации интерфейса. Примеры: localhost:5001, localhost:5000/products
Маршрут путь к методу интерфейса от конечной точки. Может определяться по умолчанию так же как в MVC или устанавливаться при помощи атрибута.

Структура приложения MicroCommerce



  1. ProductCatalog микросервис, предоставляющий сведения о продуктах.
  2. ShoppingCart микросервис, предоставляющий сведения о покупках пользователя, а также возможность добавлять/удалять покупки. При изменении состояния корзины пользователя генерируются события для уведомления других микросервисов.
  3. ActivityLogger микросервис, собирающий сведения о событиях других микросервисов. Предоставляет конечную точку для получения логов.
  4. WebUI Пользовательский интерфейс приложения, должен быть реализован в виде Single Page Application.
  5. Interfaces интерфейсы микросервисов и классы-модели.
  6. Middleware общая функциональность для всех микросервисов


Разработка приложения MicroCommerce




Создаем пустое решение .Net Core. Добавляем в него проект WebUI как пустой ASP.NET Core WebApplication. Далее добавляем проекты микросервисов ProductCatalog, ShoppingCart, ActivityLog, также как пустые проекты ASP.NET Core WebApplication. В заключение добавляем две библиотеки классов Interfaces и Middleware.

1. Interfaces интерфейсы микросервисов и классы-модели



Подключаем к проекту Nuget-пакет Shed.CoreKit.WebApi.Abstractions.

Добавляем интерфейс IProductCatalog и модели для него:
//// Interfaces/IProductCatalog.cs//using MicroCommerce.Models;using Shed.CoreKit.WebApi;using System;using System.Collections.Generic;namespace MicroCommerce{    public interface IProductCatalog    {        IEnumerable<Product> Get();        [Route("get/{productId}")]        public Product Get(Guid productId);    }}


//// Interfaces/Models/Product.cs//using System;namespace MicroCommerce.Models{    public class Product    {        public Guid Id { get; set; }        public string Name { get; set; }        public Product Clone()        {            return new Product            {                Id = Id,                Name = Name            };        }    }}

Использование атрибута Route ничем не отличается от аналогичного в ASP.NET Core MVC, но нужно помнить, что этот атрибут должен быть из namespace Shed.CoreKit.WebApi, и никакого другого. То же самое касается атрибутов HttpGet, HttpPut, HttpPost, HttpPatch, HttpDelete, а также FromBody в случае их применения.
Правила применения атрибутов типа Http[Methodname] такие же, как в MVC, то есть если префикс имени метода интерфейса совпадает с именем требуемого Http-метода, то не нужно его дополнительно определять, иначе используем соответствующий атрибут.
Атрибут FromBody применяется к параметру метода, если этот параметр должен извлекаться из тела запроса. Замечу, что как и ASP.NET Core MVC, его нужно указывать всегда, никаких правил по умолчанию нет. И в параметрах метода может быть только один параметр с этим атрибутом.

Добавляем интерфейс IShoppingCart и модели для него
//// Interfaces/IShoppingCart.cs//using MicroCommerce.Models;using Shed.CoreKit.WebApi;using System;using System.Collections.Generic;namespace MicroCommerce{    public interface IShoppingCart    {        Cart Get();        [HttpPut, Route("addorder/{productId}/{qty}")]        Cart AddOrder(Guid productId, int qty);        Cart DeleteOrder(Guid orderId);        [Route("getevents/{timestamp}")]        IEnumerable<CartEvent> GetCartEvents(long timestamp);    }}


//// Interfaces/IProductCatalog/Order.cs//using System;namespace MicroCommerce.Models{    public class Order    {        public Guid Id { get; set; }        public Product Product { get; set; }        public int Quantity { get; set; }        public Order Clone()        {            return new Order            {                Id = Id,                Product = Product.Clone(),                Quantity = Quantity            };        }    }}


//// Interfaces/Models/Cart.cs//using System;namespace MicroCommerce.Models{    public class Cart    {        public IEnumerable<Order> Orders { get; set; }    }}


//// Interfaces/Models/CartEvent.cs//using System;namespace MicroCommerce.Models{    public class CartEvent: EventBase    {        public CartEventTypeEnum Type { get; set; }        public Order Order { get; set; }    }}


//// Interfaces/Models/CartEventTypeEnum.cs//using System;namespace MicroCommerce.Models{    public enum CartEventTypeEnum    {        OrderAdded,        OrderChanged,        OrderRemoved    }}


//// Interfaces/Models/EventBase.cs//using System;namespace MicroCommerce.Models{    public abstract class EventBase    {        private static long TimestampBase;        static EventBase()        {            TimestampBase = new DateTime(2000, 1, 1).Ticks;        }        public long Timestamp { get; set; }                public DateTime Time { get; set; }        public EventBase()        {            Time = DateTime.Now;            Timestamp = Time.Ticks - TimestampBase;        }    }}

Пара слов о базовом типе событий EventBase. При публикации событий используем подход, описанный в книге, т.е. любое событие содержит метку времени создания Timestamp, при опросе источника события слушатель передает последний полученный timestamp. К сожалению, тип long некорректно преобразуется в в тип Number javascript при больших значениях, поэтому мы используем некую хитрость вычитаем timestamp базовой даты (Timestamp = Time.Ticks TimestampBase). Конкретное значение базовой даты абсолютно неважно.

Добавляем интерфейс IActivityLogger и модели для него
//// Interfaces/IActivityLogger.cs//using MicroCommerce.Models;using System.Collections.Generic;namespace MicroCommerce{    public interface IActivityLogger    {        IEnumerable<LogEvent> Get(long timestamp);    }}


//// Interfaces/Models/LogEvent.cs//namespace MicroCommerce.Models{    public class LogEvent: EventBase    {        public string Description { get; set; }    }}


2. Микросервис ProductCatalog


Открываем Properties/launchSettings.json, привязываем проект к порту 5001.
{  "iisSettings": {    "windowsAuthentication": false,    "anonymousAuthentication": true,    "iisExpress": {      "applicationUrl": "http://localhost:60670",      "sslPort": 0    }  },  "profiles": {    "MicroCommerce.ProductCatalog": {      "commandName": "Project",      "environmentVariables": {        "ASPNETCORE_ENVIRONMENT": "Development"      },      "applicationUrl": "http://localhost:5001"    }  }}

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi и ссылки на проекты Interfaces и Middleware. О Middleware будет более подробно рассказано ниже.

Добавляем реализацию интерфейса IProductCatalog
//// ProductCatalog/ProductCatalog.cs//using MicroCommerce.Models;using System;using System.Collections.Generic;using System.Linq;namespace MicroCommerce.ProductCatalog{    public class ProductCatalogImpl : IProductCatalog    {        private Product[] _products = new[]        {            new Product{ Id = new Guid("6BF3A1CE-1239-4528-8924-A56FF6527595"), Name = "T-shirt" },            new Product{ Id = new Guid("6BF3A1CE-1239-4528-8924-A56FF6527596"), Name = "Hoodie" },            new Product{ Id = new Guid("6BF3A1CE-1239-4528-8924-A56FF6527597"), Name = "Trousers" }        };        public IEnumerable<Product> Get()        {            return _products;        }        public Product Get(Guid productId)        {            return _products.FirstOrDefault(p => p.Id == productId);        }    }}


Каталог продуктов храним в статическом поле, для упрощения примера. Конечно же, в реальном приложении нужно использовать какое-то другое хранилище, которое можно получить как зависимость через Dependency Injection.

Теперь эту реализацию нужно подключить как конечную точку. Если бы мы использовали традиционный подход, мы должны были бы использовать инфраструктуру MVC, то есть создать контроллер, передать ему нашу реализацию как зависимость, настроить роутинг и т.д. С использованием Nuget-пакета Shed.CoreKit.WebApi это делается гораздо проще. Достаточно зарегистрировать нашу реализацию в Dependency Injection (services.AddTransient<IProductCatalog, ProductCatalogImpl>()), затем объявляем ее как конечную точку (app.UseWebApiEndpoint()) при помощи метода-расширителя UseWebApiEndpoint из пакета Shed.CoreKit.WebApi. Это делается в Setup

//// ProductCatalog/Setup.cs//using MicroCommerce.Middleware;using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Hosting;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;using Microsoft.Extensions.Logging;using Shed.CoreKit.WebApi;namespace MicroCommerce.ProductCatalog{    public class Startup    {        public void ConfigureServices(IServiceCollection services)        {            services.AddCorrelationToken();            services.AddCors();            // регистрируем реализацию как зависимость в контейнере IoC            services.AddTransient<IProductCatalog, ProductCatalogImpl>();            services.AddLogging(builder => builder.AddConsole());            services.AddRequestLogging();        }        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)        {            if (env.IsDevelopment())            {                app.UseDeveloperExceptionPage();            }            app.UseCorrelationToken();            app.UseRequestLogging();            app.UseCors(builder =>            {                builder                    .AllowAnyOrigin()                    .AllowAnyMethod()                    .AllowAnyHeader();            });            // привязываем реализацию к конечной точке            app.UseWebApiEndpoint<IProductCatalog>();        }    }}


Это приводит к тому, что в микросервисе появляются методы:
localhost:5001/get
localhost:5001/get/Метод UseWebApiEndpoint может принимать необязательный параметр root.
Если мы подключим конечную точку таким образом: app.UseWebApiEndpoint(products)
то конечная точка микросервиса будет выглядеть вот так:
localhost:5001/products/get
Это может быть полезно, если у нас появится необходимость подключить к микросервису несколько интерфейсов.

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

Остальной код в Setup настраивает и подключает дополнительные возможности.
Пара services.AddCors() / app.UseCors(...) разрешает использование кросс-доменных запросов в проекте. Это необходимо при редиректах запросов со стороны UI.
Пара services.AddCorrelationToken() / app.UseCorrelationToken() подключает использование токенов корреляции при журналировании запросов, как это описано в книге Кристиана Хорсдала. Мы дополнительно обсудим это позже.
И наконец, пара services.AddRequestLogging() / app.UseRequestLogging() подключает журналирование запросов из проекта Middleware. К этому тоже вернемся позже.

3. Микросервис ShoppingCart


Привязываем проект к порту 5002 аналогично ProductCatalog.

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi и ссылки на проекты Interfaces и Middleware.

Добавляем реализацию интерфейса IShoppingCart.
//// ShoppingCart/ShoppingCart.cs//using MicroCommerce.Models;using System;using System.Collections.Generic;using System.Linq;namespace MicroCommerce.ShoppingCart{    public class ShoppingCartImpl : IShoppingCart    {        private static List<Order> _orders = new List<Order>();        private static List<CartEvent> _events = new List<CartEvent>();        private IProductCatalog _catalog;        public ShoppingCartImpl(IProductCatalog catalog)        {            _catalog = catalog;        }        public Cart AddOrder(Guid productId, int qty)        {            var order = _orders.FirstOrDefault(i => i.Product.Id == productId);            if(order != null)            {                order.Quantity += qty;                CreateEvent(CartEventTypeEnum.OrderChanged, order);            }            else            {                var product = _catalog.Get(productId);                if (product != null)                {                    order = new Order                    {                        Id = Guid.NewGuid(),                        Product = product,                        Quantity = qty                    };                    _orders.Add(order);                    CreateEvent(CartEventTypeEnum.OrderAdded, order);                }            }            return Get();        }        public Cart DeleteOrder(Guid orderId)        {            var order = _orders.FirstOrDefault(i => i.Id == orderId);            if(order != null)            {                _orders.Remove(order);                CreateEvent(CartEventTypeEnum.OrderRemoved, order);            }            return Get();        }        public Cart Get()        {            return new Cart            {                Orders = _orders            };        }        public IEnumerable<CartEvent> GetCartEvents(long timestamp)        {            return _events.Where(e => e.Timestamp > timestamp);        }        private void CreateEvent(CartEventTypeEnum type, Order order)        {            _events.Add(new CartEvent            {                Timestamp = DateTime.Now.Ticks,                Time = DateTime.Now,                Order = order.Clone(),                Type = type            });        }    }}

Здесь, как и в ProductCatalog, используем статические поля как хранилища. Но этот микросервис еще использует вызовы к ProductCatalog для получения информации о продукте, поэтому ссылку на IProductCatalog передаем в конструктор как зависимость.
Теперь эту зависимость нужно определить в DI, и мы используем для этого метод-расширитель AddWebApiEndpoints из пакета Shed.CoreKit.WebApi. Этот метод регистрирует в DI фабрику-генератор WebApi-клиентов для интерфейса IProductCatalog.
При генерировании WebApi-клиента фабрика использует зависимость System.Net.Http.HttpClient. Если в приложении требуются какие-то специальные настройки для HttpClient (учетные данные, специальные заголовки/токены), это можно сделать при регистрации HttpClient в DI.

//// ShoppingCart/Settings.cs//using MicroCommerce.Middleware;using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Hosting;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;using Microsoft.Extensions.Logging;using Shed.CoreKit.WebApi;using System.Net.Http;namespace MicroCommerce.ShoppingCart{    public class Startup    {        public void ConfigureServices(IServiceCollection services)        {            services.AddCorrelationToken();            services.AddCors();            services.AddTransient<IShoppingCart, ShoppingCartImpl>();            services.AddTransient<HttpClient>();            services.AddWebApiEndpoints(new WebApiEndpoint<IProductCatalog>(new System.Uri("http://localhost:5001")));            services.AddLogging(builder => builder.AddConsole());            services.AddRequestLogging();        }        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)        {            if (env.IsDevelopment())            {                app.UseDeveloperExceptionPage();            }            app.UseCorrelationToken();            app.UseRequestLogging("getevents");            app.UseCors(builder =>            {                builder                    .AllowAnyOrigin()                    .AllowAnyMethod()                    .AllowAnyHeader();            });            app.UseWebApiEndpoint<IShoppingCart>();        }    }}

Метод AddWebApiEndpoints может принимать произвольное количество параметров, поэтому возможно настроить все зависимости одним вызовом этого метода.
В остальном все настройки аналогичны ProductCatalog.

4. Микросервис ActivityLogger


Привязываем проект к порту 5003 аналогично ProductCatalog.

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi и ссылки на проекты Interfaces и Middleware.

Добавляем реализацию интерфейса IActivityLogger.
//// ActivityLogger/ActivityLogger.cs//using MicroCommerce;using MicroCommerce.Models;using System.Collections.Generic;using System.Linq;namespace ActivityLogger{    public class ActivityLoggerImpl : IActivityLogger    {        private IShoppingCart _shoppingCart;        private static long timestamp;        private static List<LogEvent> _log = new List<LogEvent>();        public ActivityLoggerImpl(IShoppingCart shoppingCart)        {            _shoppingCart = shoppingCart;        }        public IEnumerable<LogEvent> Get(long timestamp)        {            return _log.Where(i => i.Timestamp > timestamp);        }        public void ReceiveEvents()        {            var cartEvents = _shoppingCart.GetCartEvents(timestamp);            if(cartEvents.Count() > 0)            {                timestamp = cartEvents.Max(c => c.Timestamp);                _log.AddRange(cartEvents.Select(e => new LogEvent                {                    Description = $"{GetEventDesc(e.Type)}: '{e.Order.Product.Name} ({e.Order.Quantity})'"                }));            }        }        private string GetEventDesc(CartEventTypeEnum type)        {            switch (type)            {                case CartEventTypeEnum.OrderAdded: return "order added";                case CartEventTypeEnum.OrderChanged: return "order changed";                case CartEventTypeEnum.OrderRemoved: return "order removed";                default: return "unknown operation";            }        }    }}

Здесь также используется зависимость от другого микросервиса (IShoppingCart). Но одна из задач этого сервиса слушать события других сервисов, поэтому добавляем дополнительный метод ReceiveEvents(), который будем вызывать из планировщика. Мы его добавим к проекту дополнительно.
//// ActivityLogger/Scheduler.cs//using Microsoft.Extensions.Hosting;using System;using System.Threading;using System.Threading.Tasks;namespace ActivityLogger{    public class Scheduler : BackgroundService    {        private IServiceProvider ServiceProvider;        public Scheduler(IServiceProvider serviceProvider)        {            ServiceProvider = serviceProvider;        }        protected override Task ExecuteAsync(CancellationToken stoppingToken)        {            Timer timer = new Timer(new TimerCallback(PollEvents), stoppingToken, 2000, 2000);            return Task.CompletedTask;        }        private void PollEvents(object state)        {            try            {                var logger = ServiceProvider.GetService(typeof(MicroCommerce.IActivityLogger)) as ActivityLoggerImpl;                logger.ReceiveEvents();            }            catch            {            }        }    }}

Настройки проекта аналогичны предыдущему пункту.
Дополнительно нужно только подключить добавленный ранее планировщик.

//// ActivityLogger/Setup.cs//using System.Net.Http;using MicroCommerce;using MicroCommerce.Middleware;using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Hosting;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;using Microsoft.Extensions.Logging;using Shed.CoreKit.WebApi;namespace ActivityLogger{    public class Startup    {        public void ConfigureServices(IServiceCollection services)        {            services.AddCorrelationToken();            services.AddCors();            services.AddTransient<IActivityLogger, ActivityLoggerImpl>();            services.AddTransient<HttpClient>();            services.AddWebApiEndpoints(new WebApiEndpoint<IShoppingCart>(new System.Uri("http://localhost:5002")));            // регистрируем планировщик (запустится при старте приложения, больше ничего делать не нужно)            services.AddHostedService<Scheduler>();            services.AddLogging(builder => builder.AddConsole());            services.AddRequestLogging();        }        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)        {            if (env.IsDevelopment())            {                app.UseDeveloperExceptionPage();            }            app.UseCorrelationToken();            app.UseRequestLogging("get");            app.UseCors(builder =>            {                builder                    .AllowAnyOrigin()                    .AllowAnyMethod()                    .AllowAnyHeader();            });            app.UseWebApiEndpoint<IActivityLogger>();        }    }}


5. WebUI пользовательский интерфейс.


Привязываем проект к порту 5000 аналогично ProductCatalog.

Подключаем к проекту Nuget- пакет Shed.CoreKit.WebApi. Cсылки на проекты Interfaces и Middleware нужно подключать только в том случае, если мы в этом проекте собираемся использовать вызовы к микросервисам

Строго говоря, это обычный ASP.NET проект и в нем возможно использование MVC, т.е. для взаимодействия с UI мы можем создать контроллеры, которые используют наши интерфейсы микросервисов как зависимости. Но интереснее и практичнее оставить за этим проектом только предоставление пользовательского интерфейса, а все обращения со стороны UI перенаправлять непосредственно микросервисам. Для этого используется метод-расширитель UseWebApiRedirect из пакета Shed.CoreKit.WebApi
//// WebUI/Setup.cs//using MicroCommerce.Interfaces;using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Hosting;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;using Shed.CoreKit.WebApi;using System.Net.Http;namespace MicroCommerce.Web{    public class Startup    {        public void ConfigureServices(IServiceCollection services)        {        }        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)        {            if (env.IsDevelopment())            {                app.UseDeveloperExceptionPage();            }            app.Use(async (context, next) =>            {                //  when root calls, the start page will be returned                if(string.IsNullOrEmpty(context.Request.Path.Value.Trim('/')))                {                    context.Request.Path = "/index.html";                }                await next();            });            app.UseStaticFiles();            // редиректы на микросервисы            app.UseWebApiRedirect("api/products", new WebApiEndpoint<IProductCatalog>(new System.Uri("http://localhost:5001")));            app.UseWebApiRedirect("api/orders", new WebApiEndpoint<IShoppingCart>(new System.Uri("http://localhost:5002")));            app.UseWebApiRedirect("api/logs", new WebApiEndpoint<IActivityLogger>(new System.Uri("http://localhost:5003")));        }    }}

Все очень просто. Теперь если со стороны UI придет, например, запрос к http://localhost:5000/api/products/get, он будет автоматически перенаправлен на http://localhost:5001/get. Конечно же, для этого микросервисы должны разрешать кросс-доменные запросы, но мы разрешили это ранее (см. CORS в реализации микросервисов).

Теперь осталось только разработать пользовательский интерфейс, и лучше всего для этого подходит Single Page Application. Можно использовать Angular или React, но мы просто создадим маленькую страничку с использованием готовой темы bootstrap и фреймворка knockoutjs.
<!DOCTYPE html><!-- WebUI/wwwroot/index.html --><html><head>    <meta charset="utf-8" />    <title></title>    <link rel="stylesheet" href="http://personeltest.ru/aways/cdnjs.cloudflare.com/ajax/libs/bootswatch/4.5.0/materia/bootstrap.min.css" />"    <style type="text/css">        body {            background-color: #0094ff;        }        .panel {            background-color: #FFFFFF;            margin-top:20px;            padding:10px;            border-radius: 4px;        }        .table .desc {            vertical-align: middle;            font-weight:bold;        }        .table .actions {            text-align:right;            white-space:nowrap;            width:40px;        }    </style>    <script src="http://personeltest.ru/aways/code.jquery.com/jquery-3.5.1.min.js"            integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="            crossorigin="anonymous"></script>    <script src="http://personeltest.ru/aways/cdnjs.cloudflare.com/ajax/libs/knockout/3.5.1/knockout-latest.min.js"></script>    <script src="../index.js"></script></head><body>    <div class="container">        <div class="row">            <div class="col-12">                <div class="panel panel-heading">                    <div class="panel-heading">                        <h1>MicroCommerce</h1>                    </div>                </div>            </div>            <div class="col-xs-12 col-md-6">                <div class="panel panel-default">                    <h2>All products</h2>                    <table class="table table-bordered" data-bind="foreach:products">                        <tr>                            <td data-bind="text:name"></td>                            <td class="actions">                                <a class="btn btn-primary" data-bind="click:function(){$parent.addorder(id, 1);}">ADD</a>                            </td>                        </tr>                    </table>                </div>            </div>            <div class="col-xs-12 col-md-6">                <div class="panel panel-default" data-bind="visible:shoppingCart()">                    <h2>Shopping cart</h2>                    <table class="table table-bordered" data-bind="foreach:shoppingCart().orders">                        <tr>                            <td data-bind="text:product.name"></td>                            <td class="actions" data-bind="text:quantity"></td>                            <td class="actions">                                <a class="btn btn-primary" data-bind="click:function(){$parent.delorder(id);}">DELETE</a>                            </td>                        </tr>                    </table>                </div>            </div>            <div class="col-12">                <div class="panel panel-default">                    <h2>Operations history</h2>                    <!-- ko foreach:logs -->                    <div class="log-item">                        <span data-bind="text:time"></span>                        <span data-bind="text:description"></span>                    </div>                    <!-- /ko -->                </div>            </div>        </div>    </div>    <script src="http://personeltest.ru/aways/stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"></script>    <script>        var model = new IndexModel();        ko.applyBindings(model);    </script></body></html>


//// WebUI/wwwroot/index.js//function request(url, method, data) {    return $.ajax({        cache: false,        dataType: 'json',        url: url,        data: data ? JSON.stringify(data) : null,        method: method,        contentType: 'application/json'    });}function IndexModel() {    this.products = ko.observableArray([]);    this.shoppingCart = ko.observableArray(null);    this.logs = ko.observableArray([]);    var _this = this;    this.getproducts = function () {        request('/api/products/get', 'GET')            .done(function (products) {                _this.products(products);                console.log("get products: ", products);            }).fail(function (err) {                console.log("get products error: ", err);            });    };    this.getcart = function () {        request('/api/orders/get', 'GET')            .done(function (cart) {                _this.shoppingCart(cart);                console.log("get cart: ", cart);            }).fail(function (err) {                console.log("get cart error: ", err);            });    };    this.addorder = function (id, qty) {        request(`/api/orders/addorder/${id}/${qty}`, 'PUT')            .done(function (cart) {                _this.shoppingCart(cart);                console.log("add order: ", cart);            }).fail(function (err) {                console.log("add order error: ", err);            });    };    this.delorder = function (id) {        request(`/api/orders/deleteorder?orderId=${id}`, 'DELETE')            .done(function (cart) {                _this.shoppingCart(cart);                console.log("del order: ", cart);            }).fail(function (err) {                console.log("del order error: ", err);            });    };    this.timestamp = Number(0);    this.updateLogsInProgress = false;    this.updatelogs = function () {        if (_this.updateLogsInProgress)            return;        _this.updateLogsInProgress = true;        request(`/api/logs/get?timestamp=${_this.timestamp}`, 'GET')            .done(function (logs) {                if (!logs.length) {                    return;                }                ko.utils.arrayForEach(logs, function (item) {                    _this.logs.push(item);                    _this.timestamp = Math.max(_this.timestamp, Number(item.timestamp));                });                console.log("update logs: ", logs, _this.timestamp);            }).fail(function (err) {                console.log("update logs error: ", err);            }).always(function () { _this.updateLogsInProgress = false; });    };    this.getproducts();    this.getcart();    this.updatelogs();    setInterval(() => _this.updatelogs(), 1000);}

Я не буду подробно объяснять реализацию UI, т.к. это выходит за рамки темы статьи, скажу только, что в javascript-модели определены свойства и коллекции для привязки со стороны HTML-разметки, а также функции, реагирующие на нажатие кнопок для обращения к конечным точкам WebApi, которые незаметно для разработчика перенаправляются к соответствующим микросервисам. Как выглядит пользовательский интерфейс и как он работает мы рассмотрим позднее в разделе Тестирование приложения.

6. Несколько слов об общей функциональности


Мы не затронули в этой статье некоторые другие аспекты разработки приложения, такие как журналирование, мониторинг работоспособности, аутентификация и авторизация. Это все подробно рассмотрено в книге Кристиана Хорсдала и вполне применимо в рамках вышеописанного подхода. Вместе с тем эти аспекты слишком специфичны для для каждого конкретного приложения и не имеет смысла выносить их в Nuget-пакет, лучше просто создать отдельную сборку в рамках приложения. Мы такую сборку создали это Middleware. Для примера просто добавим сюда функциональность для журналирования запросов, которую мы уже подключили при разработке микросервисов (см. пп. 2-4).
//// Middleware/RequestLoggingExt.cs//using Microsoft.AspNetCore.Builder;using Microsoft.Extensions.DependencyInjection;namespace MicroCommerce.Middleware{    public static class RequestLoggingExt    {        private static RequestLoggingOptions Options = new RequestLoggingOptions();        public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder builder, params string[] exclude)        {            Options.Exclude = exclude;            return builder.UseMiddleware<RequestLoggingMiddleware>();        }        public static IServiceCollection AddRequestLogging(this IServiceCollection services)        {            return services.AddSingleton(Options);        }    }    internal class RequestLoggingMiddleware    {        private readonly RequestDelegate _next;        private readonly ILogger _logger;        private RequestLoggingOptions _options;        public RequestLoggingMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, RequestLoggingOptions options)        {            _next = next;            _options = options;            _logger = loggerFactory.CreateLogger("LoggingMiddleware");        }        public async Task InvokeAsync(HttpContext context)        {            if(_options.Exclude.Any(i => context.Request.Path.Value.Trim().ToLower().Contains(i)))            {                await _next.Invoke(context);                return;            }            var request = context.Request;            _logger.LogInformation($"Incoming request: {request.Method}, {request.Path}, [{HeadersToString(request.Headers)}]");            await _next.Invoke(context);            var response = context.Response;            _logger.LogInformation($"Outgoing response: {response.StatusCode}, [{HeadersToString(response.Headers)}]");        }        private string HeadersToString(IHeaderDictionary headers)        {            var list = new List<string>();            foreach(var key in headers.Keys)            {                list.Add($"'{key}':[{string.Join(';', headers[key])}]");            }            return string.Join(", ", list);        }    }    internal class RequestLoggingOptions    {        public string[] Exclude = new string[] { };    }}

Пара методов AddRequestLogging() / UseRequestLogging(...) позволяет включить журналирование запросов в микросервисе. Метод UseRequestLogging кроме того может принимать произвольное количество путей-исключений. Мы воспользовались этим в ShoppingCart и в ActivityLogger чтобы исключить из журналирования опросы событий и избежать переполнения логов. Но повторюсь, журналирование, как и любая другай общая функциональность это исключительно зона ответственности разработчиков и реализуется в рамках конкретного проекта.

Тестирование приложения


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


В консолях микросервисов мы видим, что при старте UI уже запросил и получил некоторые данные. Например, для получения списка продуктов был отправлен запрос localhost:5000/api/products/get, который был перенаправлен на localhost:5001/get.





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



Микросервису ShoppingCart отправляется запрос localhost:5002/addorder/

Но поскольку ShoppingCart не хранит список продуктов, сведения о заказанном продукте он получает от ProductCatalog.



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

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

Заключение


Nuget-пакет Shed.CoreKit.WebApi позволяет:
  • полностью сосредоточиться на разработке бизнес-логики приложения, не прилагая дополнительных усилий на вопросы взаимодействия микросервисов;
  • описывать структуру микросервиса интерфейсом .NET и использовать его как при разработке самого микросервиса, так и для генерации Web-клиента (Web-клиент для микросервиса генерируется фабричным методом после регистрации интерфейса в DI и предоставляется как зависимость);
  • регистрировать интерфейсы микросервисов как зависимости в Dependency Injection;
  • организовать перенаправление запросов со стороны Web UI к микросервисам без дополнительных усилий при разработке UI.
Подробнее..

Игра на WebAssembly, часть 2 уровни и опыт, админка

01.07.2020 02:19:17 | Автор: admin
В предыдущей статье получилось сделать прототип игры с вопросами. Но не получилось сделать это полноценно на WebAssembly. В этот раз предлагаю конвертировать этот прототип, сделав с него полноценное WebAssembly приложение, а так же добавить к нему атрибуты большинства игр опыт и уровни игрока. А так же простую логику при переходе на некоторые уровни будем открывать новые навыки.

Demo



Создаем WebAssembly проект


Создадим новый проект, который будет хостить WebAssembly приложение, внутри Web приложения (ASP.NET Core ). Если быть точнее, то Web приложение возвращает WebAssembly приложение, которое остается в браузере у пользователя и взаимодействует с сервером посредством http (или веб сокетов). Для этого, нам нужно создать BlazorApp проект и выбрать пункт ASP.NET Core hosted


Или же, из консоли:
dotnet new blazorwasm --hosted


По скольку у нас уже есть Blazor проект с предыдущей статьи, а синтаксис одинаковый у проектов с серверным хостингом и WebAssembly, то все страницы можно просто скопировать. Но с классами для логики так не получится нужно создать новый контроллер, как часть backend-а. Это обычный asp.net контроллер, с которым наш клиент будет коммуницировать.

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

Меняем версию на .NET 5 preview 6


Срочной необходимости это делать нет. Но, поскольку предварительная версия .NET 5 уже доросла до шестой, поддерживает Blazor и в ней есть некоторые новые фичи (например, extension методы для запросов на сервер), то почему бы и не перейти на неё сейчас. Мигрировать довольно просто, если знать что делать. В нашем случае это замена версий target framework в серверном проекте и переход на новую версию всех nuget пакетов.

Github commit: Update to .NET 5 preview 6


Добавляем опыт и уровень


Для связи между уровнем и опытом будем использовать простую формулу: минимум опыта для уровня = 2^(уровень). Для того, чтобы игрок мог понимать сколько опыта он накопил и какого он уровня, добавим в его модель эти два параметра. А так же, добавим поле в таблицу с вопросами для индикации сколько очков опыта даст один вопрос. Пока все вопросы будут добавлять 1 очко опыта.

Для отображения на пользовательском интерфейсе, используем простые компоненты Bootstrap.
<div class="row">    <div class="col-md-auto">        <span class="badge badge-warning">            Уровень <span class="badge badge-light">@state.Level</span>        </span>    </div>    <div class="col">        <div class="progress mt-1">            <div class="progress-bar" role="progressbar" style="width: @GetExperienceWidgetWidth()" aria-valuenow="@state.Experience" aria-valuemin="0" aria-valuemax="@experienceDiff">                XP: @state.Experience            </div>        </div>    </div></div>


Результат:


Github commit: Add level and experience to UI


Навыки уровней


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

Github commit: Show features depending on level


Так же добавим рейтинг игроков с наивысшим уровнем. Это послужит примером того, как выглядит комит с отдельной компонентой, которая касается разных существующих частей. На мой взгляд, выглядит очень даже понятно.
Github commit: Add leader board


В этом же изменении, при редактировании фидбека на неправильный ответ, наткнулся на интересный момент. Попробовал одновременно подписаться на события нажатия клавиши (если это Enter отправить на сервер) и изменения значения контрола (если оно пустое убрать сообщение о не верном ответе). Оказалось, что такой вариант не поддерживается. Что, в свою очередь спровоцировало как-то это обойти. В описном тикете есть варианты, но я попробовал еще один:
 var timer = new Timer(1);timer.Elapsed += (object sender, ElapsedEventArgs e) => { wrongStyle = "visibility:hidden"; };timer.Start();

Работает именно так, как мне нужно при изменении ответа, сообщение пропадает. Хотя и таймер на 1 миллисекунду. Вообще, использования .NET таймера внутри браузера звучит странно. Но, похоже, что если пользователь не активен, то таймер автоматически замораживается.

Админка


Приятным бонусом использования Blazor внутри ASP.NET Core приложения является возможность использования инструментов зрелого фреймворка. В данном случае мы можем сделать простую админку для просмотра\редактирования вопросов с помощью кодо-генерации.

Добавим несколько типичных строк в логику конфигурации авторизации (Startup.cs) и сгенерируем пару стандартных частей Identity страницы для авторизации и контроллер с представлениями для просмотра вопросов. Получилась простыня кода, из которой руками писалось всего несколько строк.

Github commit: add admin part


Проверка ответов и безопасность


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

Выводы


На сегодняшний день есть как позитивные так и негативные моменты разработки на Blazor. Самое приятное это видеть как интерактивно меняется пользовательский интерфейс, при этом использовать только C# как язык программирования. Как ни странно, не смотря на простоту фреймворка, требуемый пользовательский интерфейс получается сделать довольно просто и при этом всё работает. Из негативных моментов главная проблема, это ощущение сыроватости. Возможно при использовании стабильных версий всё выглядит лучше.

В общем, ощущения очень интересные. Приходится использовать старые добрые инструменты для написания веб приложения совсем по-новому. Мне кажется, что пока развитие Blazor идет в очень правильном русле просто компилируемый код, который работает в браузере без всяких странных прибамбасов.

Результат:

Github
Demo
Подробнее..

Обновления ASP.NET Core в .NET 6 Preview 1

25.02.2021 10:21:06 | Автор: admin

Новая версия .NET, 6 Preview 1, уже доступна и готова к вашей оценке. Это первая предварительная версия .NET 6, следующего крупного обновления платформы .NET. Ожидается, что .NET 6 поступит в полноценный доступ в ноябре этого года и будет выпуском с долгосрочной поддержкой (LTS).

Если вы работаете с Windows и используете Visual Studio, мы рекомендуем установить последнюю предварительную версию Visual Studio 2019 16.9. Если вы используете macOS, мы рекомендуем установить последнюю предварительную версию Visual Studio 2019 для Mac 8.9.

Основная работа, запланированная с ASP.NET Core в .NET 6

.NET 6 использует открытый процесс планирования, поэтому вы можете изучить все основные темы, запланированные для этого релиза, на Blazor-веб-сайте themesof.net. В дополнение к этим верхнеуровневым темам мы собираемся также предоставить множество улучшений, ориентированных на пользователей. Вы можете найти список основных задач, запланированных для ASP.NET Core в .NET 6, в нашем выпуске дорожной карты. Вот некоторые из основных функций ASP.NET Core, запланированных для выпуска .NET 6:

Мы приветствуем отзывы и участие в процессе планирования и создания на GitHub.

Что нового в ASP.NET Core в .NET 6 Preview 1?

  • Поддержка IAsyncDisposableв MVC

  • DynamicComponent

  • InputElementReferenceразделен на релевантные компоненты

  • dotnet watchтеперь являетсяdotnet watch runпо дефолту

  • Nullable reference type annotations

Начало работы

Чтобы начать работу с ASP.NET Core в .NET 6 Preview 1, установите .NET 6 SDK.

Обновление существующего проекта

Чтобы обновить существующее приложение ASP.NET Core с .NET 5 до .NET 6 Preview 1:

  • Обновите целевую платформу для вашего приложения, доnet6.0.

  • Обновите все ссылки на пакеты Microsoft.AspNetCore.* до6.0.0-preview.1.*.

  • Обновите все ссылки на пакеты Microsoft.Extensions.* до6.0.0-preview.1.*.

См. полный список критических изменений в ASP.NET Core для .NET 6 здесь.

DynamicComponent

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

<DynamicComponent Type="@someType" />

Параметры могут быть переданы визуализируемому компоненту с помощью dictionary:

<DynamicComponent Type="@someType" Parameters="@myDictionaryOfParameters" />@code {    Type someType = ...    IDictionary<string, object> myDictionaryOfParameters = ...}

InputElementReferenceразделен на релевантные компоненты

Соответствующие встроенные компоненты Blazor ввода теперь предоставляют удобную ссылку ElementReference для базового ввода, что упрощает распространенные сценарии, такие как установка фокуса пользовательского интерфейса на вводе. Затронутые компоненты: InputCheckbox, InputDate, InputFile, InputNumber, InputSelect, InputText и InputTextArea.

dotnet watchтеперь являетсяdotnet watch runпо дефолту

Запуск dotnet watch теперь будет запускать dotnet watch run по умолчанию, экономя драгоценное время ввода.

Nullable Reference Type Annotations

Мы применяем аннотации обнуляемости к частям ASP.NET Core. Значительное количество новых API было аннотировано в .NET 6 Preview 1.

Используя новую функцию C# 8, ASP.NET Core может обеспечить дополнительную безопасность во время компиляции при обработке ссылочных типов, например защиту от исключений нулевых ссылок. Проекты, которые выбрали использование аннотаций, допускающих значение NULL, могут видеть новые предупреждения во время сборки от API-интерфейсов ASP.NET Core.

Чтобы включить ссылочные типы, допускающие значение NULL, вы можете добавить в файл проекта следующее свойство:

<PropertyGroup>    <Nullable>enable</Nullable></PropertyGroup>

Подробности читайте здесь.

Подробнее..

Из песочницы IOptions и его друзья

20.06.2020 00:17:13 | Автор: admin

Во время разработки часто возникает потребность для вынесения параметров в конфигурационные файлы. Да и вообще хранить разные конфигурационный константы в коде является признаком дурного тона. Один из вариантов хранения настроек использования конфигурационных файлов. .Net Core из коробки умеет работать с такими форматами как: json, ini, xml и другие. Так же есть возможность писать свои провайдеры конфигураций. (Кстати говоря за работу с конфигурациями отвечает сервис IConfiguration и IConfigurationProvider для доступа к конфигурациям определенного формата и для написания своих провайдеров)


image


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


На MSDN есть статья, которая должна раскрывать все вопросы. Но, как всегда, не все так просто.


IOptions


Does not support:
Reading of configuration data after the app has started.
Named options

Is registered as a Singleton and can be injected into any service lifetime.

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


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


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


IOptionsSnapshot


Is useful in scenarios where options should be recomputed on every request

Is registered as Scoped and therefore cannot be injected into a Singleton service.

Supports named options

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


MSDN нам говорит, что не может быть заинжекчен в Singletone на самом деле может (это прям тема для отдельного поста), но тогда и сам он начинает себя вести как Singletone.


IOptionsMonitor


Is used to retrieve options and manage options notifications for TOptions instances.

Is registered as a Singleton and can be injected into any service lifetime.

Supports:
Change notifications
Named options
Reloadable configuration
Selective options invalidation (IOptionsMonitorCache)

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


IOptionsMonitorCache интерфейс для построения обычного кэша на базе IOptionsMonitor.


Практика


Все тесты проводились на следующем окружении


sw_versProductName:    Mac OS XProductVersion: 10.15.5BuildVersion:   19F101dotnet --version3.1.301

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


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


В качестве примера будет простое Web API


 public class Program {     public static void Main(string[] args)     {         CreateHostBuilder(args).Build().Run();     }     public static IHostBuilder CreateHostBuilder(string[] args) =>         Host.CreateDefaultBuilder(args)             .ConfigureWebHostDefaults(webBuilder =>             {                 webBuilder.UseKestrel();                 webBuilder.UseStartup<Startup>();                 webBuilder.UseUrls("http://*:5010/");             })             .UseDefaultServiceProvider(options => options.ValidateScopes = false); }

Клиент, который будет к нему обращаться


 static async Task Main(string[] args) {     using var client = new HttpClient();     var prevResponse = String.Empty;     while (true)     {         var response = await client.GetStringAsync("http://localhost:5010/settings");         if (response != prevResponse) // пишем в консоль только, если настройки изменились         {             Console.WriteLine(response);             prevResponse = response;         }     } }

В Web API создаем 3 сервиса, который принимает все 3 варианта конфигураций в конструктор и возвращают текущее значение.


private readonly IOptions<TestGroupSettings> _testOptions;private readonly IOptionsSnapshot<TestGroupSettings> _testOptionsSnapshot;private readonly IOptionsMonitor<TestGroupSettings> _testOptionsMonitor;public ScopedService(IOptions<TestGroupSettings> testOptions, IOptionsSnapshot<TestGroupSettings> testOptionsSnapshot,    IOptionsMonitor<TestGroupSettings> testOptionsMonitor){    _testOptions = testOptions;    _testOptionsSnapshot = testOptionsSnapshot;    _testOptionsMonitor = testOptionsMonitor;}

Сервисы будут 3х скоупов: Singletone, Scoped и Transient.


public void ConfigureServices(IServiceCollection services){    services.Configure<TestGroupSettings>(Configuration.GetSection("TestGroup"));    services.AddSingleton<ISingletonService, SingletonService>();    services.AddScoped<IScopedService, ScopedService>();    services.AddTransient<ITransientService, TransientService>();    services.AddControllers();}

В процессе работы нашего Web Api изменяем значение TestGroup.Test файла appsettings.json


Имеем следующую картину:
Сразу после запуска


SingletonService IOptions value: 0SingletonService IOptionsSnapshot value: 0SingletonService IOptionsMonitor value: 0ScopedService IOptions value: 0ScopedService IOptionsSnapshot value: 0ScopedService IOptionsMonitor value: 0TransientService IOptions value: 0TransientService IOptionsSnapshot value: 0TransientService IOptionsMonitor value: 0

Изменяем нашу настройку и получаем интересную картину
Сразу после изменения


SingletonService IOptions value: 0SingletonService IOptionsSnapshot value: 0 // не измениласьSingletonService IOptionsMonitor value: 0 // не измениласьScopedService IOptions value: 0ScopedService IOptionsSnapshot value: // стала пустойScopedService IOptionsMonitor value: 0 // не измениласьTransientService IOptions value: 0TransientService IOptionsSnapshot value: // стала пустойTransientService IOptionsMonitor value: 0 // не изменилась

Следующий вывод в консоль (конфиг больше не менялся)


SingletonService IOptions value: 0SingletonService IOptionsSnapshot value: 0 // не измениласьSingletonService IOptionsMonitor value: 0 // не измениласьScopedService IOptions value: 0ScopedService IOptionsSnapshot value: changed setting // измениласьScopedService IOptionsMonitor value: 0 // не измениласьTransientService IOptions value: 0TransientService IOptionsSnapshot value: changed setting // измениласьTransientService IOptionsMonitor value: 0 // не изменилась

Последний вывод (конфиг также не менялся)


SingletonService IOptions value: 0SingletonService IOptionsSnapshot value: 0 // не измениласьSingletonService IOptionsMonitor value: changed setting // измениласьScopedService IOptions value: 0ScopedService IOptionsSnapshot value: changed setting // измениласьScopedService IOptionsMonitor value: changed setting // измениласьTransientService IOptions value: 0TransientService IOptionsSnapshot value: changed setting // измениласьTransientService IOptionsMonitor value: changed setting // изменилась

Что имеем в итоге? А имеем то, что IOptionsMonitor не такой шустрый, как нам говорит документация. Как можно заметить IOptionsSnapshot может вернуть пустое значение. Но, он работает быстрее, чем IOptionsMonitor.


Пока не особо понятно откуда берется это пустое значение. И самое интересное, что подобное поведение проявляется не всегда. Как-то через раз в моем примере IOptionsMonitor и IOptionsSnapshot отрабатывают одновременно.


Выводы


Если вам нужно передавать конфигурацию, которые никогда в процессе жизни вашего приложения не будут меняться используете IOptions.


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


Если же вам нужны наиболее актуальные значения (или почти) используйте IOptionsMonitor.


Буду рад, если вы запустите пример у себя, и расскажете, повторяется подобное поведение или нет. Возможно мы имеем баг на MacOS, а может это by design.


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

Подробнее..

Перевод Локализация в ASP.NET Core Razor Pages Культуры

26.10.2020 20:10:07 | Автор: admin

Привет, хабр! Прямо сейчас OTUS открывает набор на новый поток курса "C# ASP.NET Core разработчик". В связи с этим традиционно делимся с вами полезным переводом и приглашаем записаться на день открытых дверей, в рамках которого можно будет подробно узнать о курсе, а также задать эксперту интересующие вас вопросы.


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

Глобализация в ASP.NET Core

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

Приведенные ниже шаги добавляют базовую локализацию к Razor Pages приложению, которое создается из стандартного шаблона веб-приложения ASP.NET Core 3.0 без настроенной аутентификации. Я назвал свое приложение Localisation. Свое вы можете назвать как вам угодно, но в таком случае не забудьте про пространства имен, если будете копировать код из этой статьи.

1. Начните с открытия файла Startup.cs и добавления туда следующих using директив:

using System.Globalization;using Microsoft.AspNetCore.Localization;using Microsoft.Extensions.Options;

2. Локализация - это дополнительная фича. По умолчанию она не включена. Измените метод ConfigureServices, включив AddLocalization, что сделает различные вспомогательные сервисы локализации доступными для системы инжекции зависимостей. Затем добавьте следующий код для конфигурации RequestLocalizationOptions в приложении.

services.Configure<RequestLocalizationOptions>(options =>{   var supportedCultures = new[]    {        new CultureInfo("en"),        new CultureInfo("de"),        new CultureInfo("fr"),        new CultureInfo("es"),        new CultureInfo("ru"),        new CultureInfo("ja"),        new CultureInfo("ar"),        new CultureInfo("zh"),        new CultureInfo("en-GB")    };    options.DefaultRequestCulture = new RequestCulture("en-GB");    options.SupportedCultures = supportedCultures;    options.SupportedUICultures = supportedCultures;});

Вам необходимо указать языки или культуры, которые вы планируете поддерживать в своем приложении. Культуры представлены в .NET классом CultureInfo, который содержит информацию о форматировании чисел и дат, календарях, системах письма, порядках сортировки и других вопросах, зависящих от местности проживания конечного пользователя. Перегрузка конструктора класса CultureInfo, используемого здесь, принимает строку, представляющую имя языка и региональных параметров (культуры). Допустимые значения - это коды ISO 639-1, которые представляют язык (например, en для английского языка), с необязательным кодом субкультуры ISO 3166, который представляет страну или диалект (например, en-GB для Великобритании или en-ZA для Южной Африки). В приведенном выше примере поддерживается несколько языков, включая одну субкультуру - британский английский, который был установлен в качестве культуры по умолчанию.

3. Теперь, когда RequestLocalizationOptions настроены, их можно применить к промежуточной прослойке локализации запросов, которую необходимо добавить в метод Configure после app.UseRouting():

app.UseHttpsRedirection();app.UseStaticFiles();app.UseRouting();var localizationOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>().Value;app.UseRequestLocalization(localizationOptions);

Установка культуры запроса

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

  • QueryStringRequestCultureProvider, который получает культуру из строки запроса

  • CookieRequestCultureProvider, который получает культуру из файла cookie

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

1. Первый шаг - создать папку с именем Models и добавить в нее файл класса с именем CultureSwitcherModel.cs.

using System.Collections.Generic;using System.Globalization; namespace Localisation.Models{    public class CultureSwitcherModel    {        public CultureInfo CurrentUICulture { get; set; }        public List<CultureInfo> SupportedCultures { get; set; }    }}

2. Добавьте в проект папку с именем ViewComponents и в нее добавьте новый файл класса C# с именем CultureSwitcherViewcomponent.cs. Затем замените содержимое на следующий код:

using Localisation.Models;using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Localization;using Microsoft.AspNetCore.Mvc;using Microsoft.Extensions.Options;using System.Linq; namespace Localisation.ViewComponents{    public class CultureSwitcherViewComponent : ViewComponent    {        private readonly IOptions<RequestLocalizationOptions> localizationOptions;        public CultureSwitcherViewComponent(IOptions<RequestLocalizationOptions> localizationOptions) =>            this.localizationOptions = localizationOptions;         public IViewComponentResult Invoke()        {            var cultureFeature = HttpContext.Features.Get<IRequestCultureFeature>();            var model = new CultureSwitcherModel            {                SupportedCultures = localizationOptions.Value.SupportedUICultures.ToList(),                CurrentUICulture = cultureFeature.RequestCulture.UICulture            };            return View(model);        }    }}

3. Добавьте новую папку в папку Pages и назовите ее Components. Внутри добавьте еще одну папку с именем CultureSwitcher. Затем добавьте Razor View в default.cshtml и замените существующее содержимое следующим:

@model CultureSwitcherModel <div>    <form id="culture-switcher">        <select name="culture" id="culture-options">            <option></option>            @foreach (var culture in Model.SupportedCultures)            {                <option value="@culture.Name" selected="@(Model.CurrentUICulture.Name == culture.Name)">@culture.DisplayName</option>            }        </select>    </form></div>  <script>    document.getElementById("culture-options").addEventListener("change", () => {        document.getElementById("culture-switcher").submit();    });</script>

Компонент представления - это простой select элемент, заполненный поддерживаемыми культурами, которые были настроены в Startup. Представление, в котором он находится, использует дефолтный get метод, что означает, что отправленное значение появится в строке запроса с именем culture. QueryStringRequestCultureProvider предназначен для поиска элемента в строке запроса по ключа culture (и/или ui-culture).

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

4. На этом этапе у вас, вероятно, у вас появилось несколько красных волнистых линий в только что созданном представлении - откройте файл _ViewImports.cshtml и добавьте вторую и третью директивы using, приведенные ниже, вместе с последней строкой, которая позволяет вам использовать tag-хелпер для рендеринга компонент представления:

@using Localisation@using Localisation.Models@using System.Globalization@namespace Localisation.Pages@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers@addTagHelper *, Localisation

5. Включите компонент переключателя культуры на страницу макета, используя подход с tag-хелпером, как показано здесь в последней строке.

<div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">    <ul class="navbar-nav flex-grow-1">        <li class="nav-item">            <a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>        </li>        <li class="nav-item">            <a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>        </li>    </ul></div><vc:culture-switcher/>

6. Измените Index.cshtml, включив код в блок кода и HTML-код для таблицы, которая отображает различные биты данных:

@page@using Microsoft.AspNetCore.Localization@model IndexModel@{    ViewData["Title"] = "Home page";    var requestCultureFeature = HttpContext.Features.Get<IRequestCultureFeature>();    var requestCulture = requestCultureFeature.RequestCulture;} <div class="text-center">    <h1 class="display-4">Welcome</h1>    <p>Learn about <a href="http://personeltest.ru/aways/docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>     <table class="table culture-table">        <tr>            <td style="width:50%;">Culture</td>            <td>@requestCulture.Culture.DisplayName {@requestCulture.Culture.Name}</td>        </tr>        <tr>            <td>UI Culture</td>            <td>@requestCulture.UICulture.Name</td>        </tr>        <tr>            <td>UICulture Parent</td>            <td>@requestCulture.UICulture.Parent</td>        </tr>        <tr>            <td>Date</td>            <td>@DateTime.Now.ToLongDateString()</td>        </tr>        <tr>            <td>Currency</td>            <td>                @(12345.00.ToString("c"))            </td>        </tr>        <tr>            <td>Number</td>            <td>                @(123.45m.ToString("F2"))            </td>        </tr>    </table></div>

При первом запуске приложения культура для запроса задается AcceptHeadersCultureRequestProvider. Когда вы используете раскрывающийся список для выбора разных культур, культура задается QueryStringCultureRequestProvider. Попробуйте добавить ключ ui-culture в строку запроса с другим значением ключа culture (например, https://localhost:xxxxx/?culture=es&ui-culture=de), чтобы посмотреть, что произойдет.

Резюме

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

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


Подробнее о курсе.


Читать ещё:

Подробнее..

Как создать простое Rest API на .NET Core

03.12.2020 12:07:50 | Автор: admin

Введение

Всем привет, в данной статье будет рассказано, как с использованием технологии C# ASP.NET Core написать простое Rest Api. Сделать Unit-тесты на слои приложений. Отправлять Json ответы. Также покажу, как выложить данное приложение в Docker.

В данной статье не будет описано, как делать клиентскую (далее Front) часть приложения. Здесь я покажу только серверную (далее Back).

Что используем?

Писать код я буду в Visual Studio 2019.

Для реализации приложения, я буду использовать такие библиотеки NuGet:

  1. Microsoft.EntityFrameworkCore

  2. Microsoft.EntityFrameworkCore.SqlServer

  3. Microsoft.EntityFrameworkCore.Tools

Для тестов вот эти библиотеки:

  1. Microsoft.NET.Test.Sdk

  2. Microsoft.NETCore.App

  3. Moq

  4. xunit

  5. xunit.runner.visualstudio

Для установки пакетов нужно зайти в обозреватель пакетов NuGet, сделать это можно, нажав ПКМ по проекту, и выбрав там пункт управление пакетам NuGet

Что программировать?

Для примера я возьму сильно упрощенную модель сервиса по ремонту автомобилей. В моей модели будут работники, которые будут заниматься ремонтом, автомобили, поступающие на ремонт, и документация по ремонту, которая будет отсылаться в ответе.

Настройка Базы Данных

Для настройки базы данных нужен класс ApplicationContext (реализация будет далее) и строка подключения, которая храниться в файле appsettings.json. В этом классе будут прописаны все зависимости для генерации миграций. Строка подключения нужна для того, чтобы приложение знало в какую БД ей обращаться и с какими параметрами.

Чтобы добавить строку подключения, достаточно зайти в файл appsettings.json и прописать следующие строки:

"ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=testdb;Trusted_Connection=True;" },

Описание слоев приложения

Модели

В слое моделей будут находиться сущности, которые с помощью Entity Framework будут преобразованы в таблицы в базе данных.

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

Первая модель, которая понадобиться для описания сервиса по ремонту - модель сотрудника. Что она будет из себя представлять?

  • Уникальный идентификатор сотрудника

  • Имя сотрудника

  • Должность сотрудника

  • Номер телефона для связи с сотрудником

Следующая модель для описания сервиса - автомобили, которые будут поступать на ремонт.

  • Уникальный идентификатор автомобиля

  • Название автомобиля

  • Номер автомобиля

И последняя модель, которую мы уже будем отсылать - документ (выписка) по ремонту.

  • Уникальный идентификатор документа

  • Сотрудник, который обслуживал автомобиль

  • Автомобиль, который был на ремонте

Чтобы модели попали в базу данных, необходимо создать миграцию. Миграция - описание того, как и что будет записано в базу данных. С помощью Entity Framework миграции можно генерировать автоматически. Для этого в пакетном менеджере надо прописать команду "Add-Migration". После этого Entity Framework сгенерирует миграцию по вашим моделям, которые указаны в классе DbContext. Чтобы применить миграцию, используем команду "Update-Database", после этого ваши данные попадут в базу данных (как это применять будет описано далее).

Контроллеры

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

Для возвращаемого значения в контроллерах будут использоваться тип Json. Для этого достаточно в return прописать

new JsonResult(Ваш объект)

В данном примере, я покажу как сделать методы для GET, POST, PUT и DELETE запросов. В GET-запросе я буду выбирать все существующие документы и передавать их на Front, а в POST-запросе я буду вызывать сервис по ремонту автомобиля и возвращать выписку по ремонту, PUT будет отвечать за обновление существующего документа и DELETE за удаление документа.

DAO (Репозитории)

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

В своем приложении я сделал репозиторий, который может принимать любую модель, и выполнять такие действия как get, get all, update, create, delete.

Сервисы

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

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

Реализация

Теперь, когда описано что и как будет устроено в приложении можно приступить и к реализации.

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

При создании нового проекта, я выбрал веб-приложение ASP.NET Core, далее прописал его название (RestApi) и выбрал папку, где оно будет храниться. На экране выбора шаблона выбрал API.

Выбор шаблона приложенияВыбор шаблона приложения

Далее приступим к самому приложению.

Структура

Я разделил все приложение по папкам (также Unit-тесты в отдельном проекте) и получил вот такую структуру мое приложения:

Структура приложенияСтруктура приложения

Модели

Для реализации моделей я сделал абстрактный класс BaseModel. Он понадобиться в будущем для корректного наследования, а также в нем прописан Id каждой, модели (это помогает не дублировать код):

 public abstract class BaseModel { public Guid Id { get; set; } }

Далее вышеописанные модели:

 public class Car : BaseModel { public string Name { get; set; } public string Number { get; set; } }
 public class Document : BaseModel { public Guid CarId { get; set; } public Guid WorkerId { get; set; } public virtual Car Car { get; set; } public virtual Worker Worker { get; set; } }
 public class Worker : BaseModel { public string Name { get; set; } public string Position { get; set; } public string Telephone { get; set; } }

Репозиторий

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

Интерфейс:

public interface IBaseRepository<TDbModel> where TDbModel : BaseModel    {        public List<TDbModel> GetAll();        public TDbModel Get(Guid id);        public TDbModel Create(TDbModel model);        public TDbModel Update(TDbModel model);        public void Delete(Guid id);    }

Реализация:

    public class BaseRepository<TDbModel> : IBaseRepository<TDbModel> where TDbModel : BaseModel    {        private ApplicationContext Context { get; set; }        public BaseRepository(ApplicationContext context)        {            Context = context;        }        public TDbModel Create(TDbModel model)        {            Context.Set<TDbModel>().Add(model);            Context.SaveChanges();            return model;        }        public void Delete(Guid id)        {            var toDelete = Context.Set<TDbModel>().FirstOrDefault(m => m.Id == id);            Context.Set<TDbModel>().Remove(toDelete);            Context.SaveChanges();        }        public List<TDbModel> GetAll()        {            return Context.Set<TDbModel>().ToList();        }        public TDbModel Update(TDbModel model)        {            var toUpdate = Context.Set<TDbModel>().FirstOrDefault(m => m.Id == model.Id);            if (toUpdate != null)            {                toUpdate = model;            }            Context.Update(toUpdate);            Context.SaveChanges();            return toUpdate;        }        public TDbModel Get(Guid id)        {            return Context.Set<TDbModel>().FirstOrDefault(m => m.Id == id);        }    }

Сервис

Сервис также как и репозиторий имеет интерфейс и его реализацию.

Интерфейс:

public interface IRepairService    {        public void Work();    }

Реализация:

public class RepairService : IRepairService    {        private IBaseRepository<Document> Documents { get; set; }        private IBaseRepository<Car> Cars { get; set; }        private IBaseRepository<Worker> Workers { get; set; }        public void Work()        {            var rand = new Random();            var carId = Guid.NewGuid();            var workerId = Guid.NewGuid();            Cars.Create(new Car            {                Id = carId,                Name = String.Format($"Car{rand.Next()}"),                Number = String.Format($"{rand.Next()}")            });            Workers.Create(new Worker            {                Id = workerId,                Name = String.Format($"Worker{rand.Next()}"),                Position = String.Format($"Position{rand.Next()}"),                Telephone = String.Format($"8916{rand.Next()}{rand.Next()}{rand.Next()}{rand.Next()}{rand.Next()}{rand.Next()}{rand.Next()}")            });            var car = Cars.Get(carId);            var worker = Workers.Get(workerId);            Documents.Create(new Document {                CarId = car.Id,                WorkerId = worker.Id,                Car = car,                Worker = worker            });        }    }

Контроллер

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

ДоменноеИмя/НазваниеКонтроллера/НазваниеМетода?Параметры(если есть)

Пути гибко настраиваются с помощью специальных атрибутов (о них не в этой статье).

Мой MainController:

[ApiController]    [Route("[controller]")]    public class MainController : ControllerBase    {        private IRepairService RepairService { get; set; }        private IBaseRepository<Document> Documents { get; set; }        public MainController(IRepairService repairService, IBaseRepository<Document> document )        {            RepairService = repairService;            Documents = document;        }        [HttpGet]        public JsonResult Get()        {            return new JsonResult(Documents.GetAll());        }        [HttpPost]        public JsonResult Post()        {            RepairService.Work();            return new JsonResult("Work was successfully done");        }        [HttpPut]        public JsonResult Put(Document doc)        {            bool success = true;            var document = Documents.Get(doc.Id);            try            {                if (document != null)                {                    document = Documents.Update(doc);                }                else                {                    success = false;                }            }            catch (Exception)            {                success = false;            }            return success ? new JsonResult($"Update successful {document.Id}") : new JsonResult("Update was not successful");        }        [HttpDelete]        public JsonResult Delete(Guid id)        {            bool success = true;            var document = Documents.Get(id);            try            {                if (document != null)                {                    Documents.Delete(document.Id);                }                else                {                    success = false;                }            }            catch (Exception)            {                success = false;            }            return success ? new JsonResult("Delete successful") : new JsonResult("Delete was not successful");        }    }

Application Context

ApplicationContext класс, который унаследован от класса DbContext. В нем прописываются все DbSet. С их помощью приложение знает, какие модели должны быть в базе данных, а какие нет.

public class ApplicationContext: DbContext    {        public DbSet<Car> Cars { get; set; }        public DbSet<Document> Documents { get; set; }        public DbSet<Worker> Workers { get; set; }        public ApplicationContext(DbContextOptions<ApplicationContext> options): base(options)        {            Database.EnsureCreated();        }    }

Настройка зависимостей и инжектирования

А теперь немного про инжектирование. Правильная настройка зависимостей проекта Asp.net core позволяет упростить его работу и избежать лишнего написания кода. Все зависимости прописываются в файле Startup.cs.

Что я связывал? Я связывал интерфейс репозитория с репозиторием каждой модели (далее будет видно, что имеется ввиду), также я связал интерфейс сервиса с его реализацией.

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

Вот как выглядит мой файл Startup.cs:

public Startup(IConfiguration configuration)        {            Configuration = configuration;        }        public IConfiguration Configuration { get; }        // This method gets called by the runtime. Use this method to add services to the container.        public void ConfigureServices(IServiceCollection services)        {            string connection = Configuration.GetConnectionString("DefaultConnection");            services.AddMvc();            services.AddDbContext<ApplicationContext>(options =>                options.UseSqlServer(connection));            services.AddTransient<IRepairService, RepairService>();            services.AddTransient<IBaseRepository<Document>, BaseRepository<Document>>();            services.AddTransient<IBaseRepository<Car>, BaseRepository<Car>>();            services.AddTransient<IBaseRepository<Worker>, BaseRepository<Worker>>();        }        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)        {            if (env.IsDevelopment())            {                app.UseDeveloperExceptionPage();            }            app.UseHttpsRedirection();            app.UseRouting();            app.UseAuthorization();            app.UseEndpoints(endpoints =>            {                endpoints.MapControllers();            });        }

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

Add-Migration init (или любое другое имя)

Update-Database

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

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

Здесь я покажу как создать UNIT-тесты для контроллера и сервиса. Для тестов я сделал отдельный проект (библиотека классов .Net Core).

Тест для контроллера

public class MainControllerTests    {        [Fact]        public void GetDataMessage()        {            var mockDocs = new Mock<IBaseRepository<Document>>();            var mockService = new Mock<IRepairService>();            var document = GetDoc();            mockDocs.Setup(x => x.GetAll()).Returns(new List<Document> { document });            // Arrange            MainController controller = new MainController(mockService.Object, mockDocs.Object);            // Act            JsonResult result = controller.Get() as JsonResult;            // Assert            Assert.Equal(new List<Document> { document }, result?.Value);        }        [Fact]        public void GetNotNull()        {            var mockDocs = new Mock<IBaseRepository<Document>>();            var mockService = new Mock<IRepairService>();            mockDocs.Setup(x => x.Create(GetDoc())).Returns(GetDoc());            // Arrange            MainController controller = new MainController(mockService.Object, mockDocs.Object);            // Act            JsonResult result = controller.Get() as JsonResult;            // Assert            Assert.NotNull(result);        }        [Fact]        public void PostDataMessage()        {            var mockDocs = new Mock<IBaseRepository<Document>>();            var mockService = new Mock<IRepairService>();            mockDocs.Setup(x => x.Create(GetDoc())).Returns(GetDoc());            // Arrange            MainController controller = new MainController(mockService.Object, mockDocs.Object);            // Act            JsonResult result = controller.Post() as JsonResult;            // Assert            Assert.Equal("Work was successfully done", result?.Value);        }        [Fact]        public void UpdateDataMessage()        {            var mockDocs = new Mock<IBaseRepository<Document>>();            var mockService = new Mock<IRepairService>();            var document = GetDoc();            mockDocs.Setup(x => x.Get(document.Id)).Returns(document);            mockDocs.Setup(x => x.Update(document)).Returns(document);            // Arrange            MainController controller = new MainController(mockService.Object, mockDocs.Object);            // Act            JsonResult result = controller.Put(document) as JsonResult;            // Assert            Assert.Equal($"Update successful {document.Id}", result?.Value);        }        [Fact]        public void DeleteDataMessage()        {            var mockDocs = new Mock<IBaseRepository<Document>>();            var mockService = new Mock<IRepairService>();            var doc = GetDoc();            mockDocs.Setup(x => x.Get(doc.Id)).Returns(doc);            mockDocs.Setup(x => x.Delete(doc.Id));            // Arrange            MainController controller = new MainController(mockService.Object, mockDocs.Object);            // Act            JsonResult result = controller.Delete(doc.Id) as JsonResult;            // Assert            Assert.Equal("Delete successful", result?.Value);        }        public Document GetDoc()        {            var mockCars = new Mock<IBaseRepository<Car>>();            var mockWorkers = new Mock<IBaseRepository<Worker>>();            var carId = Guid.NewGuid();            var workerId = Guid.NewGuid();            mockCars.Setup(x => x.Create(new Car()            {                Id = carId,                Name = "car",                Number = "123"            }));            mockWorkers.Setup(x => x.Create(new Worker()            {                Id = workerId,                Name = "worker",                Position = "manager",                Telephone = "89165555555"            }));            return new Document            {                Id = Guid.NewGuid(),                CarId = carId,                WorkerId = workerId            };        }    }

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

Тест для сервиса

public class RepairServiceTests    {        [Fact]        public void WorkSuccessTest()        {            var serviceMock = new Mock<IRepairService>();            var mockCars = new Mock<IBaseRepository<Car>>();            var mockWorkers = new Mock<IBaseRepository<Worker>>();            var mockDocs = new Mock<IBaseRepository<Document>>();            var car = CreateCar(Guid.NewGuid());            var worker = CreateWorker(Guid.NewGuid());            var doc = CreateDoc(Guid.NewGuid(), worker.Id, car.Id);            mockCars.Setup(x => x.Create(car)).Returns(car);            mockDocs.Setup(x => x.Create(doc)).Returns(doc);            mockWorkers.Setup(x => x.Create(worker)).Returns(worker);            serviceMock.Object.Work();            serviceMock.Verify(x => x.Work());        }        private Car CreateCar(Guid carId)        {            return new Car()            {                Id = carId,                Name = "car",                Number = "123"            };        }        private Worker CreateWorker(Guid workerId)        {            return new Worker()            {                Id = workerId,                Name = "worker",                Position = "manager",                Telephone = "89165555555"            };        }        private Document CreateDoc(Guid docId, Guid workerId, Guid carId)        {            return new Document            {                Id = docId,                CarId = carId,                WorkerId = workerId            };        }    }

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

Запуск тестов

Чтобы запустить тесты достаточно зайти во вкладку Тест и нажать выполнить все тесты.

Выкладываем в Docker

В финале я покажу, как выложить данное приложение в Docker Hub. В Visual Studio 2019 это сделать крайне просто. Учтите, что у вас уже должен быть профиль в Docker и создан репозиторий в Docker Hub.

Нажимаете ПКМ на ваш проект и выбираете пункт опубликовать.

Там выбираем Docker Container Registry

На следующем окне, надо выбрать Docker Hub

Далее введите свои учетные данный Docker.

Если все прошло успешно, то осталось сделать последнюю вещь, нажать кнопку Опубликовать.

Готово, вы опубликовали свое приложение в Docker Hub!

Заключение

В данной статье я показал, как использовать возможности C# ASP.NET Core для создания простого Rest API. Показал, как создавать модели, записывать их в БД, как создать свой репозиторий, как использовать сервисы и как создавать контроллеры, которые будут отправлять JSON ответы на ваш Front. Также показал, как сделать Unit-тесты для слоев контроллеров и сервисов. И в финале показал, как выложить приложение в Docker.

Надеюсь, что данная статья будет вам полезна!

Подробнее..
Категории: C , Net , Asp.net core , Asp , Rest api

Бэк-офис для игр или результат борьбы с пенсионной скукой

02.02.2021 12:18:59 | Автор: admin

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

Все началось с решения написать игру для Андроида. И сразу стало понятно, что для игры потребуется WEB сервис. Нужно же где-то хранить успехи и поражения игрока, и, далее организовывать всякого вида соревнования. Ну может быть такое уже есть на просторах интернета? Искал, но, каюсь не слишком тщательно. Решил написать сам (заодно прокачать свои умения в WEB технологиях). Так появилось то, что я назвал Бэк офис для игр.

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

Основную идею и устройство системы вполне описывает структура сущностей определенных в ней. Итак:

  • Сервер системы. Это очевидно. WEB сервер, обеспечивающий выполнение заявленные сервисы системы.

  • Клиент.Игровая программа, использующая ресурсы системы.

  • Администратор системы. Уникальная роль в системе. Его функции:

Общее управление системой

Просмотр содержимого объектов системы

Поддержка разработчиков (администраторов) игр

Консультации и помощь в отладке игр.

Экспорт аккаунтов разработчиков (администраторов) игр из тестовой системы в рабочую

Публикация отлаженных на тестовой системе разработчиком игр в рабочей системе.

Блокировка/разблокировка разработчиков и/или их игр по тем или иным причинам.

Общение с администраторами игр в чате или посредством электронной почты.

Внутрисистемный арбитраж. Разрешение спорных вопросов.

Установка и изменение курсов виртуальной валюты системы для каждого из администратора игр.

  • Администратор (разработчик) игр. Уникальная роль в конкретной игре. Может быть владельцем нескольких игр. Его функции:

Создание и регистрация игр

Блокировка/разблокировка игроков

Определение стартовых ресурсов для новых игроков.

Настройка параметров игр.

Организация и ведение соревнований.

Установка курсов игровых валют.

Регулирование цен (в игровой валюте) на игровые ресурсы.

Внутри игровой арбитраж. Разрешение спорных вопросов на уровне собственных игр.

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

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

Название игры

Название игровой валюты

Стартовая сумма в единицах этой валюты при регистрации игрока в игре (стартовый бонус)

Параметр игры. Произвольный параметр, определяющий течение игры. Целое число. Разработчик имеет полную свободу назначения параметров. Параметры игры и их значения по умолчанию наследуются соревнованиями. Собственные обязательные (при создании) свойства:

  1. Название.

  2. Значение по умолчанию, целое.

Системой определен один фиксированный параметр, обязательный для всех соревнований. Это Количество фреймов в матче. Удалять его или создавать параметр с таким же названием нельзя, допускается только изменять его значение в конкретных соревнованиях. Этот параметр автоматически создается при создании новой игры. Кроме того, разработчик может добавить единственный на игру параметр с признаком Рейтинг. Этот параметр используется в соревнованиях как награда от администратора игры победителям, увеличивая их игровой рейтинг.

Тип игрового ресурса. Определяет перечень игровых ресурсов. Может принимать только два значения: Число и Список. Ресурс со списочным типом полезен для группировки ресурсов с числовым типом по категориям. Присутствие списочного типа необязательно.

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

Системная валюта Дублоны. Курс системной валюты устанавливается администратором системы для каждого администратора игр отдельно. Получается администратором игр при регистрации. Покупается у администратора системы. Расходуется при внутри игровых операциях в качестве комиссионных.

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

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

Соревнование. Непосредственный акт игры нескольких игроков с параметрами, настраиваемыми администратором игры. Параметры соревнования есть параметры игры с измененными значениями. Соревнования определены 3 видов: Чемпионат, Турнир, Первенство. Кроме соревнований разработчик может реализовать произвольный акт игры нескольких игроков с установкой значений параметров, определенных в игре (Свободный матч) одним из игроков. Система фиксирует состояние игрока относительно соревнования.

Фрейм. Минимальный игровой акт. Фрейм является неделимой единицей структуры любого соревнования.

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

Раунд. Применим только к турниру. Совокупность матчей, проводимых на одном уровне

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

  • Название.

  • Количество партнеров в матче (NP). От 2 до 10.

  • Количество раундов (NR). От 2 до 10. Важно: NPNR общее количество игроков турнира не рекомендуется делать большим, чем 256.

  • Дата окончания подписки. Регистрация игроков для участия в турнире заканчивается не позднее истечения этой даты.

  • Дата начала. Турнир начинается не ранее этой даты.

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

  • Вступительный рейтинг. Игрок с меньшим рейтингом не может участвовать в турнире.

  • Стимул от Автора. Параметр в целых единицах игровой валюты. По умолчанию 0.

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

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

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

  • Название.

  • Количество игроков. Не рекомендуется делать большим, чем 256.

  • Дата окончания подписки. Регистрация игроков для участия в чемпионате заканчивается не позднее истечения этой даты.

  • Дата начала. Чемпионат начинается не ранее этой даты.

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

  • Вступительный рейтинг. Игрок с меньшим рейтингом не может участвовать в турнире.

  • Стимул от Автора. Параметр в целых единицах игровой валюты. По умолчанию 0.

  • Количество фреймов в матче.

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

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

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

С более детальным описанием системы можно ознакомиться непосредственно на сайте в разделе Описание системы. Этот же сайт служит инструментом создания и управления игрой/играми для разработчика/администратора игр.


Автором предлагается также .NET Standard компонент GBOClientStd, предоставляющий все необходимые методы для доступа к API системы. Этот компонент доступен для скачивания на GitHub.

С целью проверки полноты и работоспособности системы автором написана Андроид игра головоломка под названием Медвежатник. Игра полностью подтвердила заявленные свойства системы, однако гарантировать отсутствие багов в ней автор не может гарантировать, поскольку он же и является на сегодня единственным тестировщиком как самой игры, так и системы в целом. По моему убеждению, самый плохой тестировщик это разработчик и, поэтому .apk игры выложен на 4pda по ссылке. Для тех, кто заинтересуется игрой маленькая подсказка: Головоломка решена когда все ручки на двери сейфа приобретают зеленый цвет, и таких ручек четное количество.

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

Подробнее..

Добавляем 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

О классах Program и Startup инициализация ASP.NET приложения. Часть I Program иIHostBuilder

30.05.2021 20:12:29 | Автор: admin

Введение. О чем эта статья.

Не так давно на Хабре я увидел статью с многообещающим названием "Что из себя представляет класс Startup и Program.cs в ASP.NET Core" (http://personeltest.ru/aways/habr.com/ru/company/otus/blog/542494/). Меня всегда нтересовало и интересует, что именно происходит под капотом той или иной библиотеки или фреймворка, с которыми мне доводится работать. И к веб-приложениям на ASP.NET Core это относится в полной мере. И я надеялся получить из этой статьи новую информацию о том, как работают упомянутые классы при запуске такого приложения. Та статья, к сожалению, меня разочаровала: в ней всего лишь в очередной раз был пересказан кусок руководства, никакой новой информации я оттуда не получил. И при чтении ее я подумал, что, наверное, есть и другие люди, которым, как и мне, интересно не просто знать, как применять тот или иной фреймворк (ASP.NET Core в данном случае), но и как он работает. А так как я по разным причинам последнее время довольно сильно углубился во внутреннее устройство ASP.NET Core, то я подумал, что теперь мне есть много что рассказать о нем из того, что выходит за рамки руководств. И вот потому я решил для начала написать статью про то, что действительно представляют из себя классы Startup и Program - так, чтобы рассказать не столько о том, как ими пользоваться (это есть в многочисленных руководствах, которые, как мне кажется, нет смысла дублировать), а, в основном, о том, как работают эти классы, причем - в контексте работы всего веб-приложения на ASP.NET Core. Однако поскольку необъятное объять нельзя, то предмет этот статьи ограничен. Прежде всего, она ограничивается рассказом только про веб-приложения, созданные с использованием нового типа шаблона приложения - Generic Host. Во-вторых, статья будет посвящена только тому, как происходит инициализация веб-приложения, потому что основная роль рассматриваемых классов именно такова - инициализация и запуск размещенного приложения. Итак, кому рассматриваемая тема, даже в столь ограниченном объеме, интересна - добро пожаловать под кат.

Введение. Продолжение.

Для начала немного расширю вводную часть статьи - потому что до ката поместилось не все, про что хотелось там написать. Но, прежде всего - краткое содержание статьи (под спойлером):

TL;DR

(сразу предупреждаю: тут - далеко не всё).

Инициализация приложения, сделанного по шаблону Generic Host выглядит следующим образом.

  1. Создается объект построителя размещения (Host), реализующий интерфейс IHostBuilder

  2. Затем выполняется стадия конфигурирования, на которой производится конфигурирование компонентов приложения - либо путем вызовов методов интерфейса IHostBuilder напрямую, либо путем вызова методов расширения для этого интерфейса - статических методов, определенных в других классах. Эти методы расширения, как правило, производят конфигурирование вызовами все тех же методов интерфейса IHostBuilder.

  3. Конфигурирование производится путем передачи в методы интерфейса IHostBuilder процедур-делегатов. Эти делегаты помещаются в очередь соответсвующего этапа, для которого они переданы (этап определяется именем вызываемого для этого метода IHostBuilder). Они будут выполнены впоследствии на соответствующих этапах построения объекта приложения Generic Host (иначе - размещения, Host).

  4. После стадии конфигурирования производится создание объекта размещения (приложения), реализующего интерфейс IHost, оно производится вызовом метода Build интерфейса IHostBuilder построителя.

  5. Создание объекта размещения в реализации по умолчанию производится построителям в несколько этапов. Эти этапы : создание конфигурации построителя, создание объектов окружения, создание конфигурации приложения, создание контейнера сервисов, включая сервисы параметров (options). После создания контейнера сервисов построитель извлекает из него реализацию интерфейса IHost и возвращает как результат вызова метода Build.

  6. На ряде этапов происходит вызов на выполнение делегатов, переданных построителю на этапе конфигурирования и хранящихся в очередях. Список этих этапов: этап создания конфигурации построителя; этап создания конфигурации приложения; подэтап конфигурирования списка регистрации сервисов этапа создания контейнера сервисов; подэтап конфигурирования контейнера-построителя этапа создания контейнера сервисов.

  7. После создания объекта приложения он запускается, что в конечно итоге приводит к запуску метода StartAsync реализации интерфейса IHost. В реализации по умолчанию этот метод выбирает из контейнера сервисов все зарегистрированные компоненты приложения - реализации интерфейса IHostedService - и асинхронно запускает их методы StartAsync. Эти методы могут выполнять инициализацию, зависящую от конкретного компонента приложения.

На этом предмет рассмотрения данной статьи заканчивается.

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

Лирическое отступление: о лирических отступлениях вообще

Когда я читал код ASP.NET Core - написанный в весьма непривычном для меня стиле - я много думал, много разного. Но в этой статье я постарался оставить все эти мысли о коде при себе, и описать в основной части статьи все максимально кратко и по существу того, что происходит в коде, что и как в нем делается - а не что я думаю при прочтении соответствующего фрагмента кода. В конце концов, именно информация о работе программы - это то основное, что, как я полагаю, хотят увидеть читатели этой статьи. А все свои мысли по поводу виденного, которые мне сдержать не удалось, я убрал под спойлер с пометкой "Лирическое отступление", отдельно от описания работы кода. Так что те читатели, кому мое личное мнение не кажется интересным, могут вообще не открывать соответствующие куски: там действительно нет никакой информации о работе фреймворка ASP.NET. И ещё: я прошу не обсуждать это мое личное мнение в комментариях к этой статье - я не считаю его настолько ценным, чтобы тратить на это время тех, кому нужна, прежде всего, информация по рассматриваемой теме (включая мое время, кстати). А обсуждать в комментариях прошу только тему самой статьи: прояснять и уточнять, как происходит инициализация веб-приложения, созданного по шаблону Generic Host, насколько удачна и понята терминология и т.д. Если же мое личное мнение о современно программировании вдруг окажется интересным более-менее заметной доле читателей - я готов изложить его для них в отдельной статье: там можно будет сделать это более систематично и подробно, и там можно обсудить его в комментариях, не отвлекая тех, кто пришел за информацией о продукте, а не за моим мнением.

детали реализации: описание убрано под спойлер

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

Вернемся однако к содержанию статьи. Для начала хочется сказать о коде ASP.NET Core в целом. Не знаю как для других, а для меня стиль написания этого кода оказался весьма непривычен. Уже при первом взгляде на файлы исходного текста, которые нам любезно генерирует мастер создания нового проекта в Visual Studio видно, что программы на ASP.NET Core принято писать в "современном, модном, молодежном" стиле. И более глубокое рассмотрение самих исходных текстов ASP.NET Core это подтверждает.

Лирическое отступление: О современных приемах написания кода

Однако, продолжу: мое внимание не могло не пройти мимо того факта, что в ASP.NET Core широко используются такие новаторские современные (и не очень) приемы написания кода, как объединение вызовов методов в цепочку через точку, методы расширения классов/интерфейсов, определенные в других классах, новые модификации синтаксиса, позволяющие писать код сокращенно ("синтаксический сахар"), вложенные функции, стрелочные (они же - лямбда-)функции (в том числе - и для написания обычных методов, и с фигурными скобками, и с вызовом других лямбда-функций внутри лямбда-функций), передача переменных в эти вложенные методы и лямбда-функции через автоматически генерируемые компилятором замыкания, в том числе - и при создании делегатов, запоминаемых в свойствах других объектов, широкое применение ключевого слова var, позволяющего компилятору (и читающему код - тоже) самому догадываться о типах определенных таким образом переменных, не менее широкое применение делегатов для задания значений полей/свойств объектов вместо простого присвоения этим полям/свойствам - короче целый арсенал приемов, ранее не используемых в C#. Да, я понимаю, что все эти приемы очень ценны, и особенно - в плане повышения продуктивности программиста при написании кода путем экономии количества знаков, необходимых для первоначальной записи программы ;-). Но конкретно мне (почему-то) читать такой код оказалось не всегда просто.

А ещё, разбираясь с исходным кодом ASP.NET, я узнал много новых приемов для написания кода в духе настоящих программистов - приемов ничуть не менее эффективных ;-) , чем освященные временем операторы GOTO и циклы DO на 5 страницах из арсенала настоящих программистов древности. И, вообще-то, я надеюсь опубликовать статью, как можно пользоваться этими приемами для написания действительно сложной для понимания программы, не навлекая на себя при этом обвинений в нарушении принципов чистоты кода и других современных верований о том, как надлежит писать программы, из которой многие, надеюсь, подчерпнут для себя знания этих приемов ;-) (как, надеюсь, все поняли из вышенаписанного, статья планируется несерьезной - ну, или наполовину серьезной). Но вернемся к рассматриваемому вопросу.

Важная особенность кода ASP.NET Core, в целом, и шаблона Generic Host, в частности - широкое использование модного современного подхода (да, я специально его не называю тут по имени, почему - см. лирическое отступление), в котором написанный программистом код работает с некими интерфейсами, получаемые тем или иным методом из специального объекта фреймворка - контейнера сервисов.

Лирическое отступление: что тебе в имени моем

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

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

Лирическое отступление: попробуй найди

Да, я слышал, что у этого подхода с сокрытием реализации интерфейсов есть многочисленные достоинства. Но и неудобства такой подход тоже создает: потому что зачастую сложно понять, какой код здесь выполняется, а ведь именно код является самой точной спецификацией программы - спецификации из документации не всегда полны/точны/бывают правильно поняты - а потому возоможность посмотреть конкретный код - она IMHO не лишняя. И IDE, которая помогает справляться со многими неясностями, в этом случае мало может подсказать, что стоит за этими интерфейсами: потому что путем статического анализа невозможно определить, какой именно код, в каком именно классе, в каком именно файле исходного текста находящийся, будет реализовывать эти интерфейсы, их методы и свойства. Это в немалой степени затрудняет, на мой взгляд, использование исходного текста фреймворка для выяснения точного поведения этого кода (что иногда бывает реально надо, особенно - при отладке).

И потому одной из задач данной статьи я считаю как раз прояснение вопроса, какие именно классы стоят за теми или иными интерфейсами в процессе выполнения приложения ASP.NET, и как именно они эти интерфейсы реализуют.

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

Далее стоит упомянуть, что исторически для ASP.NET, кроме Generic Host существует другой, более старый тип шаблона веб-приложения: Web Host. Хотя производителем (Microsoft) он в рассматриваемой в статье версии ASP.NET Core (статья писалась изначально по версии 3.1.8) объявлен нежелательным для использования в новых проектах, но он все ещё поддерживается. Эти шаблоны приложения внешне весьма сходны, но в их реализации, тем не менее, есть весьма существенные отличия, рассмотрение которых заметно бы увеличило объем и так неслабо разросшейся статьи. Поэтому решено было рассмотреть в статье только реализацию шаблона Generic Host. Далее, ни работа веб-приложения, ни компоненты (Middleware), которые могут использоваться в его работе, в этой статье рассматриваться не будут по той же самой причине: необъятное объять нельзя.
Также очень кратко, только в объеме, необходимом для понимания процесса инициализации размещения (объекта, реализующего интерфейс IHost), будут рассмотрены инициализация и работа общесистемных компонентов .NET Core, таких как упомянутый выше контейнер сервисов (он же - контейнер внедрения зависимостей, DI Container), конфигурация (Configuration), параметры (Options).

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

На первый взгляд...

Итак, начнем с начала - с начала выполнения программы. Как известно, наверное, уже всем, выполнение программы ASP.NET начинается с определенного в файле program.cs класса Program, с его метода Main. И самый первый взгляд на этот файл, сгенерированный мастером создания нового проекта в Visual Studio, подсказывает нам, что приложение выполняется в две стадии. Сначала следует стадия настройки, производимая в шаблоне автоматически генерируемым отдельным методом CreateHostBuilder: создается вызовом статического метода CreateDefaultBuilder класса Microsoft.Extension.Hosting.Host экземпляр класса, реализующего интерфейс IHostBuilder (с добавленными к нему настройками по умолчанию), а затем с помощью методов IHostBuilder и многочисленных методов расширения IHostBuilder (специфических для различных компонентов приложения), указывается, какие компоненты, и с какими настройками будут использоваться. Отдельный метод CreateHostBuilder с этим именем нужен, как сказано в документации, чтобы средства разработки для ORM Entity Framework Core могли найти контекст подключения к БД (DbContext), с которым должно работать разрабатываемое приложение (в данной статье про это ничего не будет). Затем, уже в методе Main, вызовом метода IHostBuilder.Build создается объект, реализующий интерфейс IHost. И, наконец, приложение запускается на выполнение одним из методов интерфейса IHost или методов его расширения: в сгенерированном мастером файле используется метод расширения Run, запускающий приложение на выполнение и ожидающий его завершения, но есть и другие, альтернативные методы. Так все выглядит на первый взгляд - легко и просто.

Лирическое отступление: а как же теория?

Знатоки теории могут попытаться натянуть на описанную архитектуру какой-нибудь шаблон проектирования. Я, например, встречал мнения, что ASP.NET Core реализует шаблон "Построитель" (Builder pattern). Но, IMHO лучше забыть теорию, а рассматривать все так, как оно реализовано в натуре (именно об этом будет рассказано дальше, и в подробностях). А в теоретические рассуждения - типа, что Builder pattern плохо совместим с Dependency Injection (тот самй упомянутый выше модный современный подход), которое очень глубоко внедрено в код ASP.NET Core - я вдаваться не собираюсь. Но вернемся к нашим баранам.

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

Лирическое отступление: о типах параметров лямбда-выражений.

Параметр ConfigureWebHost, при этом, как и положено правильно написанного в целях повышения сложности кода параметру лямбда-выражения, имеет неведомый нам тип. Впрочем, IDE(сразу) или документация (если ее все же прочесть) тут слегка помогут: они покажут, что тип этого параметра (он является интерфейсом) называется IWebHostBuilder. Но вот какой класс за этим интерфейсом прячется - все равно не покажут. Но я про то, что это за класс, дальше, во второй части статьи, обязательно расскажу - это очень интересный и нетривиальный класс.

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

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

На самом деле, нет

С появлением (ещё в незапамятные времена - в .NET Framework 3.5) поддержки деревьев выражений (Expression Trees) делегат может быть преобразован в такое дерево и использован внутри приложения каким-нибудь другим образом, кроме выполнения его кода. Но в коде конфигурирования и построения веб-приложений, который тут рассматривается, это, насколько я заметил, нигде не используется. Так что в этой статье можно смело писать "очевидно" ;-) .

Но где, в какой момент, и в каких условиях это происходит - документация нам про это не рассказывает. В основном, документация по этапу конфигурирования и построения приложения ASP.NET Core является сборником рецептов "как сделать", а не описанием "как это работает".

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

В результате типичное описание инициализации фреймворка превращается в некую форму декларативного программирования: для получения нужного приложения записывается набор пожеланий о том, что программист хочет получить на выходе. Правда, назвать это чисто декларативным программированием сложно: описание желаемого результата записывается на весьма необычном языке, включающем в себя вполне императивные конструкции внутри лямбда-выражений и даже полноценные методы (в Startup-классе, например). Но, в целом, описание по своей сути вполне декларативно: все эти императивно выглядящие конструкции типовой программист обычно тщательно переписывает из документации (или со StackOverflow и т.п.), изменяя в них, разве что, имена переменных. Получаются своего рода такие усложненные декларации. Я такой подход называю "заклинательным программированием": подобно магу, программист создает программу-заклинание, используя таинственные слова, точный смысл которых ему неведом, лишь вплетая в заклинание небольшие свои кусочки, чтобы добиться желаемого. И, подобно магу, программист не знает, как и почему заклинание будет выполнено - но уверен, что если не допущено ошибок, то оно обязательно сработает - и оно действительно ведь срабатывает! Подход этот древний (но, конечно, менее древний, чем магия): я видел людей, его использующих, ещё студентом, работая ещё на электронно-вычислительной машине (ЭВМ), а не на компьютере. У одного из таких людей (кстати, вполне неплохого прикладного программиста) была по этому поводу любимая присказка: "что бы такого ей (то есть ЭВМ) сказать". Причем, это явно были не первые люди, кто такой подход использовал. Лично я такой подход не люблю: мне всегда хочется не то, чтобы докопаться до первооснов, но, по крайней мере, иметь в голове модель того, что происходит. И это, кстати, послужило одной из причин, почему я стал разбираться в том материале, который вошел в эту статью.

Раскрываем тайны: этапы большого пути

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

Общая схема процесса инициализации схематически изображена на рисунке ниже:

Рис. 1. Схема процесса инициализации Generic HostРис. 1. Схема процесса инициализации Generic HostУсловные обозначения на рисунках

Рисунки-cхемы - эта, единственная в первой части, и ещё несколько во второй части - не являются формализованными диаграммами, а служат только для иллюстрации. Поэтому объекты на них изображены с некоторой степнью вольности, без строгой формализации. Но, тем не менее, специфичческий смысл у разных форм графических элементов присутствует. Прямоугольные элементы со сплошниыми границами обозначают блоки кода - как реализации методов классов, так и делегаты. Прямоугольныики со скругленными границами обозначают объекты. Элементы с штриховыми границами - элементы данных: свойства объектов и данные, передаваемые методам. Маленькие кружки на выносных линиях обозначают методы и свойства интерфейсов и классов. Сплошные линии со стрелками - направления, в которых идет выполнение кода. Штриховые линии со стрелками - передача данных в методы/свойства (а делегаты, передаваемые в методы в качестве данных, обозначены прямоугольниками в штриховых кругах). Штрих-пунктирные линии с пустотелыми стрелками на конце обозначают создание экземпляров объектов.

В изображенном на рисунке типичном процессе инициализации статический метод Host.CreateHostBuilder сначала создает объект построителя, реализующий интерфейс IHostBuilder. Затем для этого интерфейса в процессе конфигурирования вызываются (условные) присоединенные методы этого интерфейса для конфигурирования компонетов AddFeature1..AddFeatureN. В процессе конфигурирования присоединенные методы вызывают методы интерфейса IHostBuilder для регистрации делегатов, которые будут фактически выполнять конфигурирование при вызове метода Build. После окончания конфигурирования программа вызывает метод Build созданного объекта построителя, который выполняет конфигурирование. В результате этого вызова программа получает ссылку на интерфейс IHost объекта приложения (оно же - размещение, Host) и вызывает метод StartAsync этого интерфейса для запуска приложения.

Реализацией интерфейса IHostBuilder, которую создает метод CreateDefaultBuilder (статический, определен в классе Microsoft.Extensions.Hosting.Host), является класс Microsoft.Extensions.Hosting.HostBuilder(далее я буду называть его построителем, а чтобы избегать путаницы с переводом - параллельно использовать английское название интерфейса IHostBuilder). После создания построителя метод CreateDefaultBuilder добавляет в него ряд делегатов, создающих настройки по умолчанию (подробности см. в документации). Если же вам по какой-то причине эти настройки по умолчанию не нужны - вы имеете полное право создать объект построителя самостоятельно, с помощью оператора new, и конфигурировать его как угодно, что называется, "с чистого листа".

О значениях по умолчанию, фиксированных константах и т.п.

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

Теперь рассмотрим метод Build. Просмотр исходного кода класса построителя показывает, что создание объекта класса, реализующего интерфейс IHost (далее я буду называть его словом "размещение") происходит в этом методе в несколько четко определенных этапов.

Отступление - о терминологии

В англоязычной документации этот объект называется Host, но это слово сложно адекватно перевести на русский язык. Используемый самой Microsoft перевод "узел" и смысл не раскрывает, и к путанице может привести - слово "node" тоже переводится как "узел", и его смысл такой перевод передает куда точнее. По-русски точнее всего по смыслу было бы перевести Host в контексте этой статьи примерно как "разместитель", но это слово явно "не звучит". Другой более-менее точный перевод - "размещение" - не передает тот факт, что этот объект играет активную роль, а не просто является вместилищем чего-то. Но за неимением лучшего буду использовать его.

Для использования на некоторых из этапов конфигурирования действий, задаваемых пользователем фреймворка - тех самых "загадочных" лямбда-выражений - в этом классе определены (и создаются в конструкторе) поля, содержащие списки действий, специфичных для этапа. Эти поля представляют собой экземпляры обобщенного класса List<>, специализированные нужным типом делегата,

детали реализации: списки делегатов

или (для одного из этапов) - интерфейса, реализованного вспомогательным классом, создаваемым на базе делегата (причина, почему так сделано, будет объяснена при описании этапа).

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

детали реализации: другие методы IHostBuilder

Для полноты описания методов IHostBuilder, но забегая немного вперед. Еще один метод интерфейса IHostBuilder, UseServiceProviderFactory (имеющий две перегруженных формы) используется для указания объекта-фабрики, создающего один из ключевых компонентов - контейнер сервисов приложения, реализующий интерфейс IServiceProvider. Об использовании объекта фабрики для его создания будет рассказано ниже при описании стадии создания этого компонента. Ну и, кроме того, в интерфейсе IHostBuilder определено (а в классе построителя, соответственно, реализовано) свойство-словарь Properties(типа IDictionary<object,object>), которое можно использовать для передачи произвольных значений между несколькими делегатами, конфигурирующими один и тот же компонент (в том числе - и делегатами, выполняющимися на разных этапах). Более того, содержимое этого свойства будет доступно и на этапе выполнения, где интерфейс IHostBuilder уже недоступен. А доступно оно будет через контейнер сервисов (например, путем внедрения зависимостей): ссылка на этот словарь будет помещена в объект HostBuilderContext, который будет зарегистрирован в контейнере сервисов как реализация сервиса для своего собственного типа - естественно (иначе не получится), с постоянным (Singleton) временем жизни.

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

Ключевые компоненты Generic Host

Теперь, прежде чем двигаться дальше, нужно вкратце рассмотреть те ключевые компоненты, которые создаются в процессе построения приложения на базе шаблона Generic Host

Первый рассматриваемый ключевой компонент ASP.NET Core (и .NET Core в целом) - это конфигурация, представляемая интерфейсом IConfiguration Конфигурация в .NET Core делает для приложения доступным набор строк-ключей, которым сопоставлены строки-значения, получаемых от различных поставщиков.

детали реализации: перечисление того, что умеет конфигурация

В качестве поставщиков конфигурации (все они представлены объектами, реализующими интерфейс IConfigurationProvider) .NET Core может использовать довольно разнообразные объекты: переменные среды (environment), параметры командной строки, файлы различных форматов... Имеется возможность использовать дополнительные поставщики конфигурации, создавая собственные реализации интерфейса IConfigurationProvider. Можно изменять значения ключей и добавлять новые ключи - однако эти изменения остаются только в пределах выполняющейся программы: существующие поставщики конфигурации на основе постоянных объектов (таких, как файлы) эти изменения в представляемых ими постоянных объектах не фиксируют. Конфигурация поддерживают возможность получения оповещений об изменениях в объектах, доступных через поставщики конфигурации (например, после редактирования файла конфигурации), повторной загрузки изменившейся части конфигурации и оповещения об изменении объектов, которые используют эту конфигурацию. Ключи конфигурации образуют иерархическое пространство имен - т.е. могут быть составными: состоять из нескольких компонентов разделенных знаком двоеточия-имен разделов, после которых находится имя значения. Есть возможность выделять ключи, принадлежащие одному разделу, в отдельные объекты разделов конфигурации, реализующие свою, ограниченную разделом, IConfiguration. Из объекта конфигурации (обычно - из одного из разделов) можно получать значения для экземпляров объектов: свойства объектов при этом заполняются на основе одноименных значений из конфигурации, преобразованных к нужному типу: это называется привязкой (Bind) конфигурации.

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

Следующий по порядку создания, но, наверное, первый по важности ключевой компонент ASP.NET Core и .NET Core - это контейнер сервисов: объект реализующий интерфейс IServiceProvider. Именно этот объект в большинстве случаев предоставляет реализации тех самых интерфейсов, который обычно используются и в коде фреймворка, и в модулях, реализующих функциональность конкретного приложения. Сервисы, которые должен предоставлять контейнер сервисов - определяются типами интерфейсов или, реже, классов, которые требуются коду. Контейнер сервисов при обращении к нему за определенным сервисом (типом интерфейса или класса) путем вызова обобщенного метода GetService с указанием нужного параметра-типа, возвращает ссылку на объект запрошенного типа - реализующий этот интерфейс или являющийся объектом этого класса.

Исходным для создания контейнера сервисов является список регистраций сервисов с интерфейсом IServiceCollection, в который методами этого интерфейса добавляются объекты - описатели сервисов. Описатели сервисов содержат такую информацию, как тип сервиса (возвращаемого класса или интерфейса), способ реализации и время жизни сервиса. Процессы добавления описателей в список регистраций сервисов и создания из него контейнера сервисов детально описаны ниже, при описании соответствующих этапов инициализации приложения.

детали реализации: дополнительные полезные сведения о контейнере сервисов
  1. Список регистрации сервисов хранит описатели сервисов в объектах типа ServiceDescriptor. Возможно добавление в список регистраций сервисов заранее созданных объектов описателей сервисов, иногда это может быть полезным.

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

  3. При регистрации сервиса указывается его время жизни. По времени жизни сервисы разделяются на постоянные, реализации которых создаются в одном экземпляре (Singleton) и существующие в течение всего времени жизни приложения (пока существует контейнер сервисов), временные - реализации которых создаются каждый раз в момент обращения и существуют пока объект, реализующий сервис, используется в запросившем его коде (Transient), и сервисы со временем жизни ограниченной области (Scoped), которые должны запрашиваться не из основного (корневого) контейнера сервисов, а из производного от него контейнера сервисов ограниченной области, доступного через свойство ServiceProvider интерфейса IServiceScope создаваемого с помощью метода расширения CreateScope для интерфейса контейнера сервисов IServiceProvider. Объекты, реализующие сервисы со временем жизни ограниченной области существуют, пока существует соответствующая ограниченная область, а в рамках этой области существуют в единственном экземпляре.

  4. В качестве реализации сервиса может быть указан:

    а) класс: в этом случае контейнер сервисов для создания реализующего сервис объекта находит конструктор указанного класса, создает реализации для всех параметров этого конструктора, вызывает конструктор и возвращает ссылку на созданный таким образом объект;

    б) делегат-фабрика: в этом случае в этом случае контейнер сервисов для создания реализующего сервис объекта вызывает этот делегат, передавая в него в качестве аргумента ссылку на свой интерфейс IServiceProvider; делегат сам отвечает за создание нужного объекта и всех необходимых для этого объектов (для этого может использоваться переданная в качестве параметра ссылка на контейнер сервисов); контейнер сервисов возвращает ссылку на созданный делегатом-фабрикой объект;

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

Третий ключевой компонент ASP.NET Core и .NET Core инициализуемый в процессе создания приложения (а также - используемый внутри самого процесса инициализации) - это механизм параметров (Options). Параметры, передаваемые данным механизмом, представляют собой сервисы, позволяющие получать в приложении значения заранее определенного типа, причем на стадии инициализации (конфигурирования) приложения задается не само значение, а способ его получения, его источник. То есть, во-первых, параметры передаются в программу на стадии выполнения не как объекты данных, а как сервисы, реализованные в виде интерфейсов .NET, и реализация механизма параметров опирается на использование контейнера сервисов: сервисы для работы с параметрами, так же как и любые другие сервисы, регистрируются в списке регистраций сервисов, а затем, после создания контейнера сервисов на основе этого списка регистраций, становятся доступными в приложении. Для получения самих же передаваемых значений необходимо вызвать соответствующие свойства или методы этих сервисов (для доступа к параметрам есть три разных типа сервисов, отличающихся способами их использования - IOptions<>, IOptionsSnapshot<>, и IOptionsMonitor<>, их описание я здесь приводить не буду). Во-вторых, значения параметров являются строго статически типизированными, их типы задаются на стадии написания программы, и указываются как параметры-типы для обобщенных типов сервисов получения значений параметров(options). В-третьих, на стадии конфигурирования задаются не сами значения параметров, а способы получения их значений. Эти способы задаются путем добавления в контейнер сервисов специальных сервисов конфигурирования значений. Регистрация сервисов, реализующих механизм параметров (options), в принципе, может быть произведена обычными методами регистрации сервисов. Но для удобства конфигурирования этого механизма созданы специальные методы расширения для интерфейса списка регистрации сервисов IServiceCollection, и чаще всего используются именно они. Этими методами можно указать, что либо источником значения сервиса служит объект конфигурации, реализующий IConfiguration (обычно - раздел конфигурации), либо произвольный код, записываемый в виде одной или нескольких процедур-делегатов, устанавливающих это значение. Более подробное рассмотрение механизма параметров заслуживает отдельной статьи, поэтому здесь его не будет.

Рассмотрим теперь в подробностях процесс создания приложения, точнее - его объекта размещения (или хоста), реализующего интерфейс IHost Как уже было сказано, это производится методом Build интерфейса IHostBuilder. А поскольку статья рассматривает работу стандартной реализации шаблона приложения Generic Host построителя Microsoft.Extensions.Hosting.HostBuilder (или просто HostBuilder) здесь рассматривается работа метода Build этого конкретного класса.

Создание конфигурации приложения

Вот теперь можно вернуться к дальнешему изучению метода Build. Сначала в методе Build создается конфигурация приложения.

детали реализации: метод Build

Но в самом что ни на есть начале метод HostBuilder.Build проверяет, что он не был запущен повторно. В объекте класса HostBuilder для этого есть специальное булево поле _hostBuilt, которое после создания объекта имеет значение по умолчанию false. Код в начале метода проверяет, установлено ли это поле в true, и если так - выбрасывает исключение InvalidOperationException. А сразу после этой проверки поле _hostBuilt устанавливается в true.

Для создания конфигурации приложения используются два отдельных этапа, с некоторым количеством промежуточных этапов между ними, на которых создаются другие структуры данных. Это связано с тем, что для нахождения части источников конфигурации приложения - таких, как файлы конфигурации - нужно знать некую дополнительную информацию - такую, как местонахождение корневого каталога приложения - которая обычно задается по умолчанию, но может, вообще говоря, и переопределяться в конфигурации, только в другой ее части. Поэтому сначала выполняется стадия, на которой создается конфигурация размещения (Host Configuration, чаще я буду назвать ее конфигурации построителя - по месту ее использования). Это - та часть конфигурации, которая не зависит от контекста построения (о нем немного ниже), в который входит, в частности, упомянутый путь к корневому каталогу приложения. Что именно входит в конфигурацию построителя по умолчанию - см. документацию. Очередь делегатов, выполняемых на этой стадии, создается методом ConfigureHostConfiguration интерфейса IHostBuilder и хранится во внутреннем поле _configureHostConfigActions. На рис.1 она обозначена "корзиной" под номером 1.

детали реализации: конфигурация построителя

Метод ConfigureHostConfiguration принимает единственный параметр-делегат, который тоже имеет единственный параметр - ссылку на объект построителя конфигурации IConfigurationBuilder.

Создание конфигурации построителя производится внутренним методом BuildHostConfiguration().

детали реализации: создание конфигурации построителя

Этот метод создает объект-построитель конфигурации класса ConfigurationBuider(реализующий IConfigurationBuilder) - локальную переменную configBuilder(на рис.1 она представлена штриховым прямоугольником) - и добавляет в него источник конфигурации, создающий провайдер хранилища конфигурации в памяти (изначально пустого) - чтобы делать возможным установку параметров конфигурации (через свойство по умолчанию интерфейса IConfiguration) даже в случае, если для конфигурации не будет больше определен никакой другой источник. После этого вызываются все делегаты из очереди построения конфигурации размещения, которые добавляют в созданный объект с интерфейсом IConfigurationBuilder свои источники конфигурации (но могут, конечно, делать и другие действия). И, наконец, из этих источников создается (методом IConfigurationBuilder.Build()) конфигурация размещения (она же - конфигурация построителя).

Созданная конфигурация построителя запоминается в поле _hostConfiguration построителя.

После этого наступают этапы создания структур данных - объектов, содержащих информацию о контексте, в котором производится построение приложения. Первый такой объект - это объект окружения размещения (хоста), реализующий интерфейс IHostEnvironment. Его создание производится внутренним методом CreateHostingEnvironment(). Данный метод создает объект внутреннего для сборки типа HostingEnvironment, реализующий интерфейсы IHostEnvironment и IHostingEnvironment (устаревший аналог IHostEnvironment). При этом при создании объекта его свойства устанавливаются на основе значений ключей конфигурации размещения с фиксированными названиями (подробности - см. документацию). В число этих свойств входят имя приложения (ApplicationName), название среды выполнения(Environment) и путь к корневому каталогу приложения (ContentRootPath).
Если нужных ключей в конфигурации размещения нет - эти свойства устанавливаются в значения по умолчанию (опять-таки, см. документацию). И, наконец, в свойство ContentRootFileProvider созданного объекта окружения записывается вновь созданный экземпляр класса PhysicalFileProvider для пути ContentRootPath - то есть, в качестве средства доступа к файлам приложения используется (изначально и по умолчанию) обычная файловая система. Созданный объект окружения размещения запоминается в поле _hostingEnvironment объекта построителя.

Другой объект, содержащий информацию о контексте - это объект контекста построения (экземпляр класса HostBuilderContext) Он создается на следующем этапе внутренним методом CreateHostBuilderContext. В его свойствах запоминаются ссылки на другие объекты, связанные с построителем: в Properties - ссылка на одноименное свойство построителя - словарь построителя (его описание см. выше под спойлером "детали реализации: другие методы IHostBuilder"), в Environment - на только что созданный объект окружения размещения (IHostEnvironment), в Configuration - (временно) конфигурация размещения (потом она будет заменена на конфигурацию приложения). Ссылка на объект контекста построения запоминается в поле _hostBuilderContext. И вот теперь все готово для окончательного создания полной конфигурации приложения, включающей в себя все источники, и это становится следующей стадией.

Стадия окончательного создания конфигурации приложения производится внутренним методом BuildAppConfiguration. Очередь делегатов, выполняемых на этой стадии, создается методом ConfigureAppConfiguration интерфейса IHostBuilder и хранится во внутреннем поле _configureAppConfigActions. На рис.1 она обозначена "корзиной" под номером 2.

детали реализации: конфигурация приложения

Метод ConfigureAppConfiguration принимает единственный параметр-делегат, который имеет уже два параметра - ссылку на контекст построения HostBuilderContext и ссылку на объект построителя конфигурации IHostBuilder. Данный метод сначала создает построитель конфигурации - объект класса ConfigurationBuider(реализующий IConfigurationBuilder) и устанавливает для него базовым каталогом корневой каталог приложения IHostEnvironment.ContentRootPath . установка базового каталога построителя конфигурации IConfigurationBuilder производится его методом расширения SetBasePath. Этот метод записывает в словарь построителя конфигурации под ключом "FileProvider" ссылку на свежесозданный объект PhysicalPathProvider с указанным базовым каталогом. Этот провайдер затем будет использоваться в качестве файлового провайдера IFileProvider по умолчанию во всех классах-источниках конфигурации файловых провайдеров (классов-наследников FileConfigurationSource) Непонятно, однако, зачем нужно было создавать два одинаковых по смыслу объекта провайдера - здесь и для окружения размещения - работающих с одним и тем же каталогом, причем - создавать их немного разным образом.

Следующим шагом в построитель конфигурации IConfigurationBuilder добавляется как источник конфигурации созданная ранее конфигурация построителя. Тем самым ранее созданная конфигурация построителя становится частью конфигурации приложения.

детали реализации: добавление конфигурации построителя к конфигурации приложения

Делается это добавлением в список источников класса ChainedConfigurationSource, содержащий ссылку на объект конфигурации построителя, причем в конструкторе ChainedConfigurationSource указывается флаг, что за освобождение (вызов метода Dispose) этого объекта конфигурации должен будет отвечать созданный из этого источника экземпляр класс провайдера ChainedConfigurationProvider, который также будет содержать ссылку на этот объект конфигурации.

Затем построение конфигурации приложения завершается аналогично построению конфигурации построителя.

детали реализации: завершение построения конфигурации приложения

Затем к построителю конфигурации IConfigurationBuilder последовательно применяются делегаты из очереди конфигурации приложения _configureAppConfigActions, получающие в качестве аргументов ссылку на ранее созданный контекст построителя и на построитель конфигурации. Что именно входит в конфигурацию приложения по умолчанию - также см. документацию. После применения всех делегатов производится создание объекта конфигурации приложения методом IConfigurationBuilder.Build.

Полученная конфигурация (интерфейс IConfiguration) сохраняется в поле _appConfiguration объекта построителя.

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

Создание контейнера сервисов

Для начала - немного о том, как выглядит процесс создания контейнера сервисов. Выполняется этот вызовами двумя последовательными вызовами методов интерфейса фабрики контейнера сервисов IServiceProviderFactory. Этот интерфейс, как мы видим, является обобщенным, с параметром-типом, который является типом промежуточного объекта, создаваемого первым методом, и принимаемого вторым. Точный смысл этого параметра-типа я, честно говоря, так до конца и не понял, т.к. ни в документации, ни в изученных мной исходных текстах примеров его нетривиального использования не встречается: фабрика контейнера сервисов по умолчанию (о ней см. ниже) использует тривиальный вариант, в котором этот тип совпадает с типом списка регистраций сервисов IServiceCollection и никакого дополнительного конфигурирования с использованием этого типа не производится. В документации и в коде его в разных местах называют по-разному - то ContainerBuilder(как вышеприведенный параметр-тип, только без префикса T), то просто Container. В тексте я буду называть его "контейнер-построитель". Судя по всему, он используется где-то для конфигурирования процесса подключения контейнера сервисов стороннего типа: в .NET Core есть возможность использовать другие контейнеры сервисов, кроме реализации по умолчанию, и несколько из них официально поддерживаются, однако я со сторонними контейнерами дела не имел, а потому достоверно утверждать об этом не могу. В самой же ASP.NET Core используется только упомянутый уже тривиальный вариант по умолчанию.

Вернемся, однако, к интерфейсу фабрики контейнера сервисов IServiceProviderFactory. Первый метод, CreateBuilder, принимает в качестве аргумента список регистраций сервисов IServiceCollection services) и возвращает объект контейнера-построителя, который (я тут забегаю вперед) может быть подвергнут дополнительному конфигурированию. После этого второй метод интерфейса фабрики CreateServiceProvider принимает аргумент - контейнер-построитель и возвращает созданный на его основе контейнер сервисов (интерфейс IServiceProvider).

Для установки используемой при построения контейнера сервисов фабрики в процессе построения размещения (IHost) в интерфейсе построителя IHostBuilder существует метод UseServiceProviderFactory (его параметр тип - это тип контейнера-построителя), имеющий две перегруженных формы: первая принимает в качестве параметра ссылку на интерфейс фабрики заранее созданного объекта, вторая - делегат, принимающий в качестве параметра объект контекста построения HostBuilderContext и возвращающий ссылку на интерфейс фабрики контейнера сервисов, реализуемый объектом, который выбирается или создается на основе содержимого контекста построения на этапе создания контейнера сервисов. Ссылка на объект фабрики контейнера сервисов, которая будет использована, сохраняется во внутреннем поле построителя (на рис.1 оно обозначено штриховым кругом справа).

детали реализации: хранение фабрики контейнера сервисов

В объекте построителя, однако, для хранения используется поле _serviceProviderFactory, имеющее тип адаптера фабрики контейнера сервисов - внутреннего интерфейса IConfigureContainerAdapter. Этот интерфейс является необобщенным - по причине того, что класс HostBuilder, реализующий построитель, также является необобщенным и не имеет параметра-типа, соответствующего типу контейнера-построителя - а потому этот тип нельзя использовать в качестве типа поля этого класса. Методы интерфейса адаптера фабрики IConfigureContainerAdapter в целом аналогичны методам самой фабрики IServiceProviderFactory, но с некоторыми различиями. Первое различие - в том, что метод CreateBuilder имеет дополнительный параметр - контекст построителя HostBuilderContext: он нужен для использования фабрики контейнера сервисов, получаемой от делегата, переданного через вторую форму метода UseServiceProviderFactory. Второе различие - в том, что методы IConfigureContainerAdapter вместо недоступного им типа контейнера-построителя используют универсальный тип Object. Реализуется этот интерфейс, однако, обобщенным внутренним классом ConfigureContainerAdapter, поэтому некоторый статический контроль (или вывод) типов все-таки производится - за счет конструктора этого класса в методе UseServiceProviderFactory, а потому объект, передаваемый во второй метод, CreateServiceProvider имеет правильный тип. Но вот соответствие типа контейнера-построителя, указанного при вызове метода UseServiceProviderFactory сигнатурам делегатов, помещенных в очередь конфигурирования контейнера-построителя (см. ниже) контролируется только динамически при их приведении к нужному для делегата типу (Из-за чего возможно возникновение исключения).

По умолчанию построитель использует реализацию фабрики контейнера сервисов на основе класса DefaultServiceProviderFactory. Этот класс в качестве типа контейнера-построителя использует тип списка регистраций сервисов IServiceCollection, т.е. реализует интерфейс IServiceProviderFactory. Некоторые свойства создаваемого контейнера сервисов можно задать с помощью параметра его конструктора - и это используется в одном из методов инициализации построителя веб-приложения (об этом - позже, во второй части).

детали реализации: о параметре конструктора

Конструктор может принимать параметр класса ServiceProviderOptions - параметры (options) контейнера сервисов. Этот класс имеет два публичных свойства-флага: ValidateScopes - проверять, не производится ли разрешение сервиса со временем жизни ограниченной области (Scoped) из корневого контейнера сервисов (используемого вне ограниченных областей) и ValidateOnBuild - при создании контейнера сервисов выполнить проверку, что можно создать все зарегистрированные в нем сервисы. По умолчанию параметр конструктора установлен в значение ServiceProviderOptions.Default, в котором оба эти флага сброшены. Параметр, переданный в конструктор, сохраняется во внутреннем поле.

Метод CreateBuilder класса DefaultServiceProviderFactory, реализующего фабрику контейнера сервисов по умолчанию, просто возвращает переданный в него список регистраций сервисов. Метод CreateServiceProvider этого класса использует метод расширения BuildServiceProvider для интерфейса IServiceCollection.

детали реализации: метод CreateServiceProvider

Он возвращает значение, получаемое от BuildServiceProvider, вызываемого для ссылки на интерфейс IServiceCollection, переданной в CreateServiceProvider как параметр - "контейнер-построитель". При вызове в качестве аргумента используется сохраненное значение в конструкторе значение ServiceProviderOptions - параметры построения контейнера сервисов.

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

Вернемся к процессу создания контейнера сервисов внутренним методом CreateServiceProvider построителя, и рассмотрим подробнее стадии этого процесса. На первой стадии создается объект списка регистраций сервисов с интерфейсом IServiceCollection - он записывается в локальную переменную services(на рис.1 она представлена штриховым прямоугольником). А потом в этот список добавляются (регистрируются) описатели сервисов, реализующих базовые сервисы фреймворка.

детали реализации: список регистраций сервисов

Реальный класс объекта, реализующего список регистраций сервисов - внутренний класс ServiceCollection. Устроен этот класс весьма прямолинейно: в нем есть внутреннее поле типа List (содержащийся в нем объект создается в конструкторе пустым), и все методы интерфейса IServiceCollection напрямую отображаются на соответствующие методы этого объекта. Базовые сервисы фреймворка - это следующие сервисы (все они регистрируются как сервисы с постоянным (Singleton)временем жизни). Во-первых - сервисы, реализацией которых являются экземпляры объектов, ранее созданных в процессе работы метода Build: IHostEnvironment и его устаревший аналог IHostingEnvironment (реализация - экземпляр объекта типа HostingEnvironment из поля _hostingEnvironment) и HostBuilderContext (регистрируется в качестве сервиса не интерфейс, а именно класс) (реализация - объект этого класса из поля _hostBuilderContext. Во-вторых, сервисы, реализацией являются классы, реализующие соответствующий интерфейс. Это - сервисы, которые связаны с запуском, остановом, и отслеживанием этапов работы приложения (в данной статье они подробно не рассматриваются): IHostLifetime (реализация - класс ConsoleLifetime), IHostApplicationLifetime (реализация - класс ApplicationLifetime) и его устаревший аналог IApplicationLifetime (последний реализуется с помощью фабрики, получающей реализацию IHostApplicationLifetime - ссылку на объект класса ApplicationLifetime - и возвращающий этот результат, преобразованный к типу IApplicationLifetime) Сервис для интерфейса IConfiguration регистрируется в виде фабрики - лямбда-функции, возвращающей конфигурацию приложения - значение поля _appConfiguration класса построителя (это значение сохраняется в замыкании этой лямбда-функции). Мотив сделать именно так - вызвать метод Dispose для конфигурации приложения при уничтожении контейнера сервисов. Но для этого требуется ещё одно дополнительное действие - фиктивное получение ссылки на этот сервис (см. спойлер в конце описания работы внутреннего метода CreateServiceProvider построителя). Кроме того, методами расширения для интерфейса IServiceCollection регистрируются группы сервисов, реализующие компоненты параметров (options) - методом AddOptions, и регистрации (logging) - методом AddLogging

Кроме этих интерфейсов регистрируется (с постоянным (Singleton) временем жизни основной интерфейс приложения, построенного на шаблоне Generic Host - IHost: он реализуется внутренним классом Internal.Host, его мы затронем немного позднее.

На второй стадии создания контейнера сервисов - стадии конфигурирования списка регистраций сервисов - к созданному списку регистраций сервисов IServiceCollection применяются делегаты из очереди конфигурирования списка регистраций сервисов _configureServicesActions. На рис.1 она обозначена "корзиной" под номером 3. Для добавления делегатов в эту очередь служит метод ConfigureServices. Делегат, добавляемый в эту очередь должен принимать два параметра: контекст построения HostBuilderContext и ссылку на конфигурируемый список регистраций сервисов IServiceCollection.

На третьей стадии создания контейнера сервисов с помощью фабрики сервисов создается промежуточный объект контейнера-построителя - он записывается в локальную переменную containerBuilder (на рис.1 она представлена штриховым прямоугольником).

детали реализации: создание контейнера-построителя

Это делается вызовом метода CreateBuilder адаптера фабрики контейнера сервисов IConfigureContainerAdapter, ссылка на который хранится в поле _serviceProviderFactory Ссылка на созданный контейнер-построитель запоминается в локальной переменной containerBuilder

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

На четвертой стадии создания контейнера сервисов - стадии конфигурирования контейнера-построителя - к контейнеру-построителю применяются элементы очереди конфигурирования контейнера-построителя _configureContainerActions. На рис.1 она обозначена "корзиной" под номером 4. Для добавления элементов в эту очередь служит метод ConfigureContainer. В рассматриваемом нами случае "чистой" (без сторонних контейнеров сервисов) ASP.NET Core этот этап, похоже, не используется. Об этом, например, говорит тот факт, что в документации даже не отражена возможность использования Startup-класса на этом этапе конфигурирования, хотя поддержка метода, производящего такое конфигурирование, в коде реализована (об этом будет рассказано подробно при рассмотрении реализации метода расширения UseStartup интерфейса IWebHostBuilder).

Поэтому сведения о реализации этого этапа целиком убраны под спойлер (и там - детали реализации и лирическое отступление)

ConfigureContainer - это обобщенный метод, имеющий один параметр-тип, совпадающий с типом контейнера-построителя. И этот параметр-тип входит в сигнатуру параметров-делегатов, которые в него передаются: делегаты должны принимать два параметра - контекст построения типа HostBuilderContext и ссылку на контейнер-построитель, тип которой - это тип контейнера-построителя, то есть - значение параметра-типа метода ConfigureContainer. А так, как значение типа контейнера-построителя на уровне всего класса построителя на этапе компиляции не известно, то элементами очереди являются не сами делегаты (потому что их реальный тип не известен и не может быть использован для специализации экземпляра класса List<>, используемого в качестве очереди), а ссылки на необобщенный внутренний интерфейс IConfigureContainerAdapter, реализуемый внутренним объектом обобщенного типа ConfigureContainerAdapter, специализированным типом контейнера-построителя, использованным указанным в сигнатуре делегата (то есть - типом второго параметра делегата). Этот делегат передается в качестве параметра в конструктор этого объекта и запоминается в его внутреннем поле. Метод ConfigureContainer этого интерфейса по сигнатуре аналогичен инкапсулированному делегату, но принимает в качестве типа контейнера-построителя универсальный тип Object. А реализация этого метода в классе ConfigureContainerAdapter состоит в приведении полученной ссылки на контейнер-построитель к типу второго параметра запомненного делегата и вызове этого делегата. Такое решение: палка о двух концах: с одной стороны, это позволяет довольно просто поместить все делегаты конфигурирования контейнера-построителя в одну очередь, но с другой - препятствует статической проверке соответствия типов параметров делегатов типу контейнера-построителя. А это несоответствие может привести к возникновению исключения недопустимого преобразования типов. Причем, поскольку в реализации метода ConfigureContainer в типе ConfigureContainerAdapter аргумент ссылки на контейнер-построитель просто безо всяких проверок приводится к типу, принимаемому делегатом, то исключение возникнет в месте, не контролируемом разработчиком, и приводит к прерыванию выполнения метода Build построителя. Разработчик может бороться с этим, разве что, используя делегаты с универсальным типом Object в качестве типа контейнера-построителя и проверяя тип контейнера-построителя внутри делегата. Но IMHO это - так себе решение.

На следующей, пятой стадии из контейнера-построителя создается объект контейнера сервисов.

детали реализации: создание контейнера сервисов из контейнера-построителя

Точнее - корневого контейнера сервисов: попытка получения из него сервиса со временем жизни ограниченной области (Scoped) считается ошибкой, потому что полученный сервис реально будет иметь постоянное время жизни, эквивалентное Singleton - а это, вероятно, не то, что ожидал разработчик, указывая время жизни ограниченной области (Scoped). В некоторых режимах - например, в режиме разработки (окружении Development) - такие действия реально проверяются и, в случае их обнаружения выбрасывается исключение InvalidOperationException. За производство такой проверки отвечает флаг ValidateScopes параметра типа ServiceProviderOptions, передаваемого при использовании фабрики контейнера сервисов по умолчанию DefaultServiceProviderFactory, в метод расширения BuildServiceProvider для интерфейса IServiceCollection, который производит создание контейнера сервисов. Само создание делается вызовом метода CreateBuilder адаптера фабрики контейнера сервисов IConfigureContainerAdapter, ссылка на который хранится в поле _serviceProviderFactory, а ссылка на созданный контейнер-построитель запоминается в локальной переменной containerBuilder

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

детали реализации: дополнительные действия

Точнее - почти заканчивается: дополнительно производится еще фиктивный запрос сервиса IConfiguration - чтобы сервис был помечен как запрошенный, это нужно чтобы реализующий его объект _appConfiguration был вовремя освобожден (вызовом Dispose()) контейнером сервисов (подробности в этой статье не рассматриваются - они явно выходят за пределы рассматриваемой темы)

И последнее, что делает метод Build класса построителя HostBuilder - это получает из контейнера сервисов реализацию интерфейса IHost (объект класса Internal.Host), которую возвращает в качестве результата.

Лирическое отступление: О торжестве подхода внедрения зависимостей

На поверхности все выглядит так, что для получения реализации интерфейса IHost метод Build просто запрашивает его у контейнера сервисов. А уж контейнер сервисов сам находит нужный класс Internal.Host, отыскивает его конструктор, который имеет немало параметров-зависимостей, разрешает внутри себя эти зависимости, создает экземпляр этого класса, указав все нужные параметры и, наконец, возвращает ссылку на запрошенный интерфейс, реализованный этим экземпляром. Это выглядит, вроде бы, как существенное упрощение, показывающее преимущества внедрения зависимостей. Но это - только видимость: объекты почти для всех этих параметров-интерфейсов были созданы в том же самом методе Build, так что передать их в конструктор класса более традиционным путем не составило бы никакого труда. Единственный параметр Internal.Host, который не создается кодом внутри HostBuilder.Build таким путем - сервис, реализующий параметр (option) для типа HostOptions: его значение задается одним из методов конфигурирования, применяемым разработчиком конкретного приложения. Но фактически единственный параметр, который передается таким образом - таймаут завершения - мог бы быть куда проще передан традиционным образом - через параметр типа Timespan. Так что процесс получения IHost в HostBuilder.Build сам по себе демонстрирует лишь саму технику использования внедрения зависимостей, но никак не ее преимущества.

Что происходит потом

Последний этап инициализации приложения, компоненты которого размещены в полученном размещении IHost - этап, специфичный для каждого из компонентов. Он происходит в процессе запуска приложения, который производится методом IHost.StartAsync - прямо или косвенно, через методы расширения этого интерфейса, которые внутри себя вызывают StartAsync. Метод StartAsync стандартной реализации IHost - класса Internal.Host - запускает (уже асинхронно) каждый из размещенных компонентов приложения методом IHostedService.StartAsync: все компоненты реализуют интерфейс IHostedService. С помощью этого метода компоненты выполняют специфичную для них инициализацию. Об инициализации, выполняемым компонентом веб-приложения будет подробно рассказано во второй части статьи. Кроме того, метод StartAsync стандартной реализации IHost производит еще ряд действий, которые в данной статье не рассматриваются.

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

Продолжение: будет скоро опубликовано. Оно уже написано (и даже выложенно в мой блог, но в не до конца причесанном виде).

Подробнее..
Категории: C , Net , Asp.net core , Internals

Миграция с .NET Core 2.2 на .NET Core 3.1 на примере реального проекта

21.07.2020 12:10:27 | Автор: admin

image
Эта статья является логическим продолжением обновления проекта nopCommerce бесплатной CMS с открытым исходным кодом для создания интернет-магазинов. В прошлый раз мы рассказали о нашем опыте миграции проекта с ASP.NET MVC на ASP.NET Core 2.2. Теперь мы рассмотрим процесс миграции на .NET Core 3.1. Учитывая, что официальная поддержка .Net Core 3.1 будет длиться до декабря 2022 года, сейчас тема миграции очень актуальна. Поэтому, если вы хотите получить все преимущества обновленного фреймворка, идти в ногу с технологическими новинками и соответствовать набирающим популярность общемировым трендам, то самое время заняться миграцией.


Какие задачи предстояло решить в процессе перехода на .NET Core 3.1


Для быстро развивающегося проекта в области электронной коммерции, крайне важно уделять большое внимание производительности системы и ее безопасности. Уже в первых review .NET Core 3.0 анонсировалось, что новая версия фреймворка будет в разы производительней, появится многоуровневая компиляция и как следствие уменьшение времени запуска, встроенная высокопроизводительная и не требовательная к памяти поддержка JSON. Маршрутизация конечной точки, которая появилась в версии .NET Core 2.2 была улучшена. Основное преимущество в том, что теперь маршрут определяется до запуска промежуточного программного обеспечения. На момент выхода релиза эти ожидания подтвердились на практике. Далее вы узнаете, что именно повлияло на производительность системы и как эволюционировал .NET Core с момента выхода версии 2.2.


Что нового дает переход на .NET Core 3.1


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


Generic Host


В .NET Core 2.1 Generic Host является неким дополнением к Web Host, это позволяет использовать такие инструменты, как внедрение зависимостей (DI) и протоколирование абстракций. В .NET Core 3.х был сделан акцент на большую совместимость с Generic Host, теперь вы можете использовать обновленный Generic Host Builder вместо Web Host Builder. Это дает возможность создавать любые приложения, начиная от консольных приложений и WPF и заканчивая веб-приложениями на одной базовой хостинговой парадигме с одинаковыми общими абстракциями.


public class Program{    public static void Main(string[] args)    {        CreateHostBuilder(args).Build().Run();    }    public static IHostBuilder CreateHostBuilder(string[] args)    {        return Host.CreateDefaultBuilder(args)            .UseServiceProviderFactory(new AutofacServiceProviderFactory())            .ConfigureWebHostDefaults(webBuilder =>            {                webBuilder                    .UseStartup<Startup>();            });    }}

Вы все еще можете продолжать использовать WebHostBuilder, но необходимо понимать что некоторые типы устарели в ASP.NET Core 3.1, и могут быть заменены в следующей версии.



global.json


Сама идея и возможность использования этой фичи уже существовала в версии .NET Core 2.0, но ее возможности были неполными. Можно было только указать версию SDK, которую использует ваше приложение. Более гибкое управление версией пакета SDK стало доступным только в .NET Core 3.0 с появлением таких политик как allowPrerelease и rollForward. Ниже представлен наш вариант комбинации политики наката версий .NET Core SDK.


{  "sdk": {    "version": "3.1.201",    "rollForward": "latestFeature",    "allowPrerelease": false  }}

Полную версию кода приложения вы можете посмотреть в нашем репозитории на GitHub.


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


ASP.NET Core Module V2


До .NET Core 2.2 IIS по умолчанию размещал приложение .NET Core, выполняя экземпляр Kestrel (встроенный веб-сервер .NET Core) и перенаправляя запросы из IIS в Kestrel. В основном IIS действовал как прокси. Это работает, но медленно, так как выполняется двойной переход от IIS к Kestrel для обработки запроса. Этот метод хостинга получил название OutOfProcess.



В .NET Core 2.2 была представлена новая модель хостинга под названием InProcess. Вместо того чтобы IIS пересылал запросы в Kestrel, он обслуживает запросы внутри IIS. Это намного быстрее при обработке запросов, потому что не нужно пересылать запрос в Kestrel. Однако это была необязательная функция, и не использовалась по умолчанию.



В .NET Core 3.1 внутрипроцессная модель размещения уже настроена по умолчанию, что значительно повышает пропускную способность запросов ASP.NET Core в IIS. При тестировании мы фиксировали двукратное увеличение производительности системы при большом количестве запросов.


<PropertyGroup>    <TargetFramework>netcoreapp3.1</TargetFramework>    <Copyright>Copyright (c) Nop Solutions, Ltd</Copyright>    <Company>Nop Solutions, Ltd</Company>    <Authors>Nop Solutions, Ltd</Authors>    <Version>4.4.0.0</Version>    <Description>Nop.Web is also an MVC web application project, a presentation layer for public store and admin area.</Description>    <PackageLicenseUrl>https://www.nopcommerce.com/license</PackageLicenseUrl>    <PackageProjectUrl>https://www.nopcommerce.com/</PackageProjectUrl>    <RepositoryUrl>https://github.com/nopSolutions/nopCommerce</RepositoryUrl>    <RepositoryType>Git</RepositoryType>    <!--Set this parameter to true to get the dlls copied from the NuGet cache to the output of your project-->    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>    <!--When true, compiles and emits the Razor assembly as part of publishing the project-->    <RazorCompileOnPublish>false</RazorCompileOnPublish>  </PropertyGroup>

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


Маршрутизация Endpoint Routing


В .NET Core 2.1 маршрутизация выполнялась в Middleware (промежуточном программном обеспечении ASP.NET Core MVC) в конце конвейера обработки HTTP-запросов. Это означает, что информация о маршруте, например, какое действие контроллера будет выполнено, была недоступна промежуточному ПО, которое обработало запрос до промежуточного ПО MVC в конвейере запросов. Поэтому начиная с .NET Core 2.2 была введена новая система маршрутизации, основанная на конечных точках (Endpoints), призванная решить вышеупомянутые проблемы.
Теперь в .NET Core 3.1 система маршрутизации Endpoint Routing построена иначе, этап маршрутизации отделен от вызова конечной точки. По сути мы имеем два промежуточных middleware:


  • EndpointRoutingMiddleware тут определяется, какая конечная точка будет вызвана для каждого пути URL запроса, по сути выполняя роль маршрутизации
  • EndpointMiddleware вызывает конечную точку


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


/// <summary>/// Configure Endpoints routing/// </summary>/// <param name="application">Builder for configuring an application's request pipeline</param>public static void UseNopEndpoints(this IApplicationBuilder application){    //Add the EndpointRoutingMiddleware    application.UseRouting();    //Execute the endpoint selected by the routing middleware    application.UseEndpoints(endpoints =>    {        //register all routes        EngineContext.Current.Resolve<IRoutePublisher>().RegisterRoutes(endpoints);    });}

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


С# 8.0 синтаксический сахар


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



Подробное описание вы можете найти в документации.


Замеры производительности в приложении


После того как мы обновили наше приложение nopCommerce, нам необходимо было проверить на сколько именно в нашем случае увеличилась производительность, т.к. для пользователей это один из самых важных критериев выбора eCommerce платформы.
Тесты мы проводили под управлением Windows 10 (10.0.19041.388), где IIS 10 (10.0.19041.1) выступал в качестве прокси-сервера для веб-сервера Kestrel на той же машине. Для моделирования нагрузки мы использовали Apache JMeter, который позволяет имитировать очень серьезную нагрузку со множеством параллельных запросов. Для теста мы специально подготовили среднестатистическую базу данных интернет магазина среднего бизнеса, где количество продуктов составляло около 50 тысяч наименований, количество категорий продуктов 516 с произвольной вложенностью, количество зарегистрированных пользователей порядка 50 тысяч, и количество заказов около 80 тысяч с рандомным включением от 1 до 5 продуктов. Все это под управлением MS SQL Server 2017 (14.0.2014.14).
JMeter выполнял несколько нагрузочных тестов, генерируя каждый раз сводный отчет об измерении каждого из запросов. Усредненные результаты после нескольких прогонов представлены ниже.


Время прохождения теста стало быстрее примерно на 20% (меньше лучше)



Среднее время отклика (Average) быстрее примерно на 13.7% (меньше лучше)



Количество запросов на единицу времени, т.е. пропускная способность (throughput) увеличилось на 12.7% (больше лучше)



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



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


Для сравнения мы приведем таблицу с результатами испытаний, в том числе и для AspNetCoreModule, который мы использовали с .NET Core 2.2. Новый модуль несомненно выигрывает у своего предшественника.



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



Итоги


Процесс миграции платформы .NET Core по-прежнему остается трудоемким и требующим времени мероприятием. И если делать обновления вовремя и последовательно, это позволит избежать огромного количества ошибок, а главное экономить время. Многие аспекты .NET Core были улучшены, бонусом вы получаете улучшенную производительность. В нашем конкретном случае мы фиксировали увеличение производительности в среднем на 13%. Это привело к тому, что запросы к приложению от наших клиентов выполняются быстрее, что позволяет отправлять больше данных за меньшее время, делая работу с приложением более комфортной. Поэтому это довольно существенная прибавка, учитывая, что вам фактически не требуется проводить performance refactoring вашего приложения: можно просто обновить фреймворк и получить общее повышение эффективности платформы.
Также немаловажным является тот факт, что, как и всегда, в .NET Core много внимания уделяется вопросам безопасности, к слову, с момента релиза .NET Core 3.1.0 уже успел выйти ряд обновлений (последняя версия на текущий момент 3.5.1) в том числе и обновлений безопасности security patch.
Очень важным моментом является и то, что .NET Core 3.1 является очередной версией LTS после 2.1, что гарантирует нам и нашим клиентам поддержку и получение самых последних исправлений, в том числе и исправлений безопасности. Поэтому для нас важно двигаться вперед вместе с выходом LTS версий .NET Core. Следующим глобальным выпуском версии LTS с долгосрочной поддержкой станет .NET 5, и перенос приложения на .NET Core 3.1 это лучший способ подготовиться к этому.
В дальнейшем мы планируем и дальше обновлять наше приложение nopCommerce, добавляя все новые и новые возможности которые предоставляет платформа .NET Core. Одна из них это переход на использование System.Text.Json вместо Newtonsoft.Json. Это более производительный, безопасный и стандартизованный подход к обработке JSON объектов. Также в планах внедрить и использовать как можно больше фичей, которые предоставляет C# 8.0.


Узнать больше о нашем проекте вы можете на сайте nopcommerce.com или посетив наш репозиторий на GitHub.

Подробнее..

Из песочницы Sheduler удобный распорядок вызова функций, моя система кондиций

14.10.2020 14:07:29 | Автор: admin

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

Но я, думаю, прибегну к нему и в других проектах, и не только на юнити.

И, возможно, он пригодится и вам!


Ссылка на исходник



И так, для чего он?


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

Следом одна за одной приходят задачи:

  1. А давай добавим рекламы на финише каждые 1,5 минуты?
  2. А давай после нее где-нибудь на старте будет окно: Купите премиум чтобы не было рекламы?
  3. Че-то это окно резко давит, а давай оно будет после рекламы, но только когда игрок нажал старт и прошел два уровня?
  4. А давай на 10, 20, 30 уровне будет окно поделитесь с друзьями?
  5. А давай с 10 уровня каждые 2 левела будет окно Оцените нас!?
  6. И кучу еще всего!

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

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




Задача


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



Решение


Для этого я сделал класс Condition, вот основные его поля:

  1. таймер int setedSeconds
  2. скипы int setedSkips
  3. список чекПойнтов List &ltint&gt checkPoints

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

Таймер и скипы работают вместе, почти одинаково, оба имеют обратный отсчет. Разве что, для скипов необходимо придумать, на каком событии они будут убавляться, метод NextSkip(). Если значение таймера = 0, то он не активен, тоже самое и
с скипами. Но даже если вы установили какое-нибудь значение, все равно нужно обязательно вызвать START() он запустит их отсчет. Вы можете сделать
это индивидуально, в конкретных для себя случаях методы StartTimer() и ResetSkips(). Также, общий метод IsReady()
возвращает true только когда те поля, что были активны (value > 0), после вызова метода START() достигли своего пика.


В общем все просто: нужен таймер ставите время (в секундах) таймеру setedSeconds, скипы оставляете на нуле, или наоборот, или все вместе!

IsReady() во всем разберется, главное всегда вызывать общий метод START() в том месте, где вы хотите, чтобы начался отсчет скипов.


    public Condition myCondition;    void Start(){        myCondition = new Condition("Имя");        myCondition.setedSeconds = 120; // 2 минуты        myCondition.setedSkips = 5;        myCondition.START(); // Отсчет пошел с самого старта игры    }    // Финиш какого нибудь уровня    public void FinishRound(){        myCondition.NextSkip(); // Левел пройден, скип убавился        if (myCondition.IsReady())        {            // Мы достигли нужной кондиции, теперь можно выполнять какую нибудь функцию...            myCondition.START(); //На этот раз START() как сброс на исходное. Обязательно, иначе каждый финиш будет IsReady == true        }    }

Кондиция, как было написано выше, может хранить в себе отдельное условие список чек пойнтов List &ltint&gt checkPoints. Этот список работает немного по-другому по той причине, что он, как правило приоритетный, т.е, нужен, если вы хотите какой-либо явный вызов функции строго по чекПойнтам. За чекПойнты можно принимать все, что угодно: пройденные лвлы, нажатия на кнопку или любые другие события (в основном, это уровни). Список чекПойнтов можно добавить исключительно через конструктор, он прилично инкапсулирован в целях дальнейшего удобного добавления в Sheduler класс, да и вряд ли будут причины по ходу программы менять список, ведь его идея в ясности момента вызова.


    public Condition myCondition;    void Start(){        myCondition = new Condition("имя",new List<int> { 1, 2, 5 }); // добавляем после обязательного имени, наш список лвлов        myCondition.setedSeconds = 120;        myCondition.setedSkips = 5;        myCondition.START();    }    public void FinishRound(){        myCondition.NextSkip();         if (myCondition.IsReady() || myCondition.HasCheckPoint(currentLevel)) // добавили в условие имеет ли кондиция в списке текущий Левел        {            // Мы достигли нужной кондиции, теперь можно выполнять какую нибудь функцию...            myCondition.START();        }    }

Ну, если механика становится ясна, можно переходить на коробку автомат вообще без проблем =) У кондиции есть AutoInvoke(Action CallBack, int checkPoint = 0) который освобождает от таких вызовов, как NextSkip() или повторного START() для перезапуска условий, но первый START() все равно требуется.


    public Condition myCondition;    void Start(){        myCondition = new Condition("имя",new List<int> { 1, 2, 5 });        myCondition.setedSeconds = 120;        myCondition.setedSkips = 5;        myCondition.START();     }    public void FinishRound(){        myCondition.AutoInvoke(() => Debug.Log("hello World"), currentLevel);         // можно без currentLevel, но тогда список чекПойнтов не будет учитываться                                                                             }


Один объект кондиции поможет вам быстро предоставить набор необходимых условий для срабатывания какой-либо одной функции!!!

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


Я добавил в поля кондиции полезные юнити аттрибуты для удобной инициализации через инспектор!


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



next Задача


Гибко добавлять разные окна (вызов функции) с разных мест программы, сохранять синхронизацию и избегать конфликтов наложения!



Решение:

И вот мы добрались до главного класса Sheduler наш планировщик кондиций!
Объект данного класса лучше инициализировать как можно раньше. Конкретно в юнити лучше, чтобы объект был DontDestroyOnLoad.

Если заглянуть внутрь Sheduler, можно увидеть там такие поля:

  1. Текущий чек пойнт int currentСheckPoint
  2. Коллекция всех добавленных кондиций и их поведения Dictionary &ltCondition,Action&gt ConditionHandlers чтобы
    планировщик знал, какую роль должна выполнить готовая кондиция
  3. Список чек пойнтов Dictionary &ltint,Condition&gt CheckPoints когда кондиция добавляется в Sheduler, её список добавляется в этот Dictionary, обязательно проверяя, свободен ли данный ключ. В противном случае он занимает след свободный чек пойнт.
  4. Очередь всех готовых кондиций Queue &ltCondition&gt WaitingConditions Если несколько кондиций становятся готовыми одновременно,
    они откладываются на следующий вызов

Sheduler хранит поведение каждой кондиции и срабатывает согласно этому классу, задается он в момент добавления кондиции public void Add(Condition newCondition, Action CallBack), где в аргументах есть обязательный делегат. Сам метод считывает имя кондиции и выбрасывает исключение, если оно пустое или уже добавлено это нужно на случай, если по какой-то причине вам надо взять кондицию из расписания по имени List&ltCondition&gt GetConditions(params string[] conditionName). Также, метод добавления Add() Сразу запускает Start() добавленной кондиции. Это полезно, если запустить Start() добавленной кондиции забудет кто из разработчиков, а также для того, чтобы избежать постоянного выбрасывания этой функции от Sheduler. Если вам нужно другое место для старта кондиции, вы просто работаете с кондицией как раньше, вы всегда можете менять её счетчики. В этом вся прелесть Sheduler он обрабатывает, где кондиция готова, и где она изменила своей готовности, и делает этот расчет в момент вызова своего главного метода Condition Invoke(params Condition[] badges). В аргументах вы можете указать некие бэйджики, т.е те кондиции, исключительно которые должны сработать, и те, чья очередь подошла, однако они не появились в списке бейджиков, то они не сработают. Но, если ничего не указывать, то, как и положено, каждый имеет право на вызов на пике очереди!

Обязательно придумайте, где будет отсчет чек пойнтов для Sheduler NextCheckPoint(), например, на методе, на финише или старте раунда
полный пример того, что требуется для работы с Sheduler:

    public Condition OfferBuyVip;    public Condition OfferToShareWithFriends;    public Condition OfferToVisitSite;    public Sheduler OfferSheduler;    public void Start(){        OfferSheduler = new Sheduler(currentLevel); // в конструкторе следует указать текущий левел        /*         * здесь или в юнити инспекторе заполните поля ваших кондиций         */        OfferSheduler.Add(OfferBuyVip, () => Debug.Log("Не желаете ли вы купить VIP"));        OfferSheduler.Add(OfferToShareWithFriends, () => Debug.Log("Вы можете поделиться с друзьями"));        OfferSheduler.Add(OfferToVisitSite, () => Debug.Log("Перед игрой лучше посетить наш сайт, там есть подсказки"));    }    public void FinishRound(){        OfferSheduler.NextCheckPoint(currentLevel); // обязательный метод, он также убавляет скипы всем кондициям        OfferSheduler.Invoke(OfferBuyVip, OfferToShareWithFriends) // будут срабатывать две кондиции при условии, что они на пике    }    public void StartRound(){        OfferSheduler.Invoke(OfferToVisitSite); // на старте раунда будет предлагаться        //посещение сайта при условии, что кондиция готова, и на этом чек пойнте не было других вызовов от Sheduler    }



Вот так мы добились того, что три функции наших кондиций вызываются в разных местах, при этом они уважают друг друга и не вылезают все подряд, а соблюдают очередь (как современная цифровая очередь по талончикам), и юзер, быстро перепрыгивая с финиша на старт игры, не будет напрягаться от кол-ва предложений. С Sheduler соблюдает четкую гармонию простоты и гибкости, ведь с Sheduler и делегатом, передаваемым ему через метод Add(Condition newCondition, Action CallBack), возможно реализовать любую связь между окнами.

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

    void Start(){        OfferSheduler = new Sheduler(currentLevel);        callAddBanner = new Condition("Вызов Рекламы");        callAddBanner.setedSeconds = 80; // Кондиция настроенна на 80 секунд        OfferBuyVip = new Condition("Предложение купить VIP без рекламы");        OfferSheduler.Add(callAddBanner,             delegate()            {                Debug.Log("ЗАПУСК РЕКЛАМ");                OfferBuyVip.setedSkips = 2; // Ставим счетчик скипов                OfferBuyVip.START();  // Запускаем            }           );        OfferSheduler.Add(OfferBuyVip,            delegate ()            {                Debug.Log("Не желаете ли вы купить VIP");                OfferBuyVip.setedSkips = 0; // Обязательно сбрасываем! Запуск рекламы //решает, когда запускать            }           );        }                void Finish(){            OfferSheduler.NextCheckPoint(currentLevel); // не забываем про отсчет// пойнтов            OfferSheduler.Invoke(); // Теперь на финише будут //вызываться согласно порядку наши кондиции        }

Вот так вот просто теперь каждые 80 сек будет вызываться не отвлекающая реклама (Ведь она вызывается не во время важного раунда, а на финише) и ещё вызывать предложение о покупке рекламы тогда, когда вам это удобно! И самое прекрасное, что теперь любой разработчик в команде может добавлять в Sheduler свои предложения, и Sheduler все распределит.
Подробнее..
Категории: Net , Unity , Asp.net core , Visual studio

Переход с 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