Иногда хочется автоматически создавать текстовые файлы, подставляя в шаблоны значения каких-то полей. Например, это могут быть исходники классов-хелперов на основе какого-то интерфейса, какие-то отчеты в XML, которые хотя и можно сгенерировать полностью программно, но на практике это может быть достаточно трудный для сопровождения код. Наверное, те, кто сталкивался с такой потребностью, смогут дополнить этот список. Приведу для примера задачу с хелперами.
Проблема
Предположим, мы создаём клиент-серверное приложение. На сервере у нас крутится MVC ASP.NET, основное клиентское приложение - WPF, но не исключено использование в будущем и других клиентов.
Чтобы при разработке модели как на клиенте, так и на сервере не заниматься сериализацией и десериализацией, мы решили клиент-серверное взаимодействие устроить по принципу коннектора, когда на клиенте вызывается некоторый метод, что приводит к вызову такого же (почти) на сервере.
Сама концепция здесь не важна, главное, что мы столкнулись с необходимостью строить исходники хелперов по интерфейсу клиентской части коннектора. Данный интерфейс можно считать недорогим вариантом Contract First парадигмы.
Пусть интерфейс такой:
using Net.Leksi.RestContract;
using System.Collections.ObjectModel;
namespace DtoKit.Demo;
public interface IConnector
{
[RoutePath("/shipCalls/{filter}/{amount:double}/{date}")]
[HttpMethodGet]
[Authorization(Roles = "1, 2, 3")]
Task GetShipCalls(DateTime date, double amount, ShipCallsFilter filter,
ObservableCollection<IShipCallForList> list);
[RoutePath("/form")]
[HttpMethodPost]
Task Commit([Body] IShipCall shipCall);
}
Здесь мы используем свои атрибуты, чтобы не включать в клиент зависимость от платформы ASP.NET, параметры, входящие в роутинг или являющиеся телом POST-запроса, должны быть в серверном методе, соответствующие атрибуты ASP.NET и пераметры из роутинга должны быть в контроллере. Остальные возможные параметры "остаются на клиенте". Всё должно однообразно сериализоваться/десериализоваться.
Мы хотим, чтобы при перестроении сборки, содержащей IConnector
, автоматически консольным приложением создавались следующие исходники.
Родительский класс для клиентской части
//------------------------------
// Connector base
// DtoKit.Demo.DemoConnectorBase
// (Generated automatically)
//------------------------------
using Microsoft.Extensions.DependencyInjection;
using Net.Leksi.Dto;
using Net.Leksi.RestContract;
using System.Net.Http.Json;
using System.Text.Json;
using System.Web;
namespace DtoKit.Demo;
public class DemoConnectorBase
{
private readonly HttpConnector _httpConnector;
public DemoConnectorBase(HttpConnector httpConnector)
{
_httpConnector = httpConnector;
}
public Task<HttpResponseMessage> GetShipCalls(DateTime date, Double amount,
ShipCallsFilter filter)
{
DtoJsonConverterFactory getConverter = _httpConnector.Services
.GetRequiredService<DtoJsonConverterFactory>();
getConverter.KeysProcessing = KeysProcessing.OnlyKeys;
JsonSerializerOptions getOptions = new();
getOptions.Converters.Add(getConverter);
string _date = HttpUtility.UrlEncode(
JsonSerializer.Serialize(date, getOptions));
string _amount = HttpUtility.UrlEncode(
JsonSerializer.Serialize(amount, getOptions));
string _filter = HttpUtility.UrlEncode(
JsonSerializer.Serialize(filter, getOptions));
string route = $"/shipCalls/{_filter}/{_amount}/{_date}";
HttpRequestMessage httpRequest = new(HttpMethod.Get, route);
return _httpConnector.SendAsync(httpRequest);
}
public Task<HttpResponseMessage> Commit(IShipCall shipCall)
{
string route = $"/form";
HttpRequestMessage httpRequest = new(HttpMethod.Post, route);
DtoJsonConverterFactory postConverter = _httpConnector.Services
.GetRequiredService<DtoJsonConverterFactory>();
JsonSerializerOptions postOptions = new();
postOptions.Converters.Add(postConverter);
httpRequest.Content = JsonContent.Create(shipCall, typeof(IShipCall),
default, postOptions);
return _httpConnector.SendAsync(httpRequest);
}
}
которую мы будем использовать в самом
коннекторе:
using Net.Leksi.RestContract;
using System.Collections.ObjectModel;
namespace DtoKit.Demo;
public class Connector : DemoConnectorBase, IConnector
{
public Connector(HttpConnector httpConnector) : base(httpConnector) { }
public async Task GetShipCalls(DateTime date, double amount,
ShipCallsFilter filter, ObservableCollection<IShipCallForList> list)
{
// Возможная логика до отправки запроса
HttpResponseMessage response = await base.GetShipCalls(date, amount,
filter);
Console.WriteLine(response.StatusCode);
// Возможная логика после получения ответа, включая запись результата в
// ObservableCollection<IShipCallForList> list для тображения в UI
// (этот параметр на сервере неизвестен)
}
public async Task Commit(IShipCall shipCall)
{
HttpResponseMessage response = await base.Commit(shipCall);
Console.WriteLine(response.StatusCode);
}
}
На сервере мы собираемся в
качестве контроллера разместить:
//------------------------------
// MVC Controller proxy
// DtoKit.Demo.DemoControllerProxy
// (Generated automatically)
//------------------------------
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Net.Leksi.Dto;
using System.Text.Json;
namespace DtoKit.Demo;
public class DemoControllerProxy : Controller
{
[Route("/shipCalls/{filter}/{amount:double}/{date}")]
[HttpGet]
[Authorize(Roles = "1, 2, 3")]
public async Task GetShipCalls(String date, Double amount, String filter)
{
DtoJsonConverterFactory converter = HttpContext.RequestServices
.GetRequiredService<DtoJsonConverterFactory>();
JsonSerializerOptions options = new();
options.Converters.Add(converter);
DateTime _date = JsonSerializer.Deserialize<DateTime>(date, options);
ShipCallsFilter _filter = JsonSerializer.Deserialize<ShipCallsFilter>(
filter, options);
Controller controller = (Controller)HttpContext.RequestServices
.GetRequiredService<IDemoController>();
controller.ControllerContext = ControllerContext;
await ((IDemoController)controller).GetShipCalls(_date, amount, _filter);
}
[Route("/form")]
[HttpPost]
public async Task Commit()
{
DtoJsonConverterFactory converter = HttpContext.RequestServices
.GetRequiredService<DtoJsonConverterFactory>();
JsonSerializerOptions options = new();
options.Converters.Add(converter);
IShipCall shipCall = await HttpContext.Request
.ReadFromJsonAsync<IShipCall>(options);
Controller controller = (Controller)HttpContext.RequestServices
.GetRequiredService<IDemoController>();
controller.ControllerContext = ControllerContext;
await ((IDemoController)controller).Commit(shipCall);
}
}
и интерфейс реального контроллера, содержащего логику:
интерфейс реального контроллера, содержащего логику:
//------------------------------
// MVC Controller interface
// DtoKit.Demo.IDemoController
// (Generated automatically)
//------------------------------
namespace DtoKit.Demo;
public interface IDemoController
{
Task GetShipCalls(DateTime date, Double amount, ShipCallsFilter filter);
Task Commit(IShipCall shipCall);
}
Нам бы могла подойти платформа RazorPages
Если бы можно было применить её в приложении командной строки, мы бы использовали для нашей цели следующие шаблоны.
Шаблон для родительского класса коннектора
@page
@using Net.Leksi.RestContract
@model Net.Leksi.RestContract.Pages.ConnectorBaseModel
//------------------------------
// Connector base
// @string.Join(".", new string[] { Model.NamespaceValue, Model.ClassName})
// (Generated automatically)
//------------------------------
@foreach(string usng in Model.Usings)
{
<text>using @usng;
</text>
}
namespace @Model.NamespaceValue;
public class @Model.ClassName
{
private readonly HttpConnector _httpConnector;
public @Model.ClassName@{<text/>}(HttpConnector httpConnector)
{
_httpConnector = httpConnector;
}
@foreach (MethodModel mm in Model.Methods)
{
<text>
public Task<HttpResponseMessage> @mm.Name@{<text/>}(@for(int i = 0; i < mm.Parameters.Count; ++i)
{
if(i > 0){<text>, </text>}
<text>@mm.Parameters[i].Type @mm.Parameters[i].Name</text>
})
{@if(mm.HasSerialized)
{
<text>
DtoJsonConverterFactory @mm.GetConverterVariable = _httpConnector.Services.GetRequiredService<DtoJsonConverterFactory>();
@mm.GetConverterVariable@{<text/>}.KeysProcessing = KeysProcessing.OnlyKeys;
JsonSerializerOptions @mm.GetOptionsVariable = new();
@mm.GetOptionsVariable@{<text/>}.Converters.Add(@mm.GetConverterVariable);</text>
foreach(Tuple<string, string, string> tuple in mm.Deserializing)
{
<text>
@tuple.Item1 @tuple.Item2 = HttpUtility.UrlEncode(JsonSerializer.Serialize(@tuple.Item3, @mm.GetOptionsVariable));</text>
}
} @* @if(mm.HasSerialized) *@
string @mm.RouteVariable = @Html.Raw($"$\"{@mm.RouteValue}\"");
HttpRequestMessage @mm.HttpRequestVariable = new(HttpMethod.@mm.HttpMethod, @mm.RouteVariable);
@if(mm.PostConverterVariable is { })
{
<text>
DtoJsonConverterFactory @mm.PostConverterVariable = _httpConnector.Services.GetRequiredService<DtoJsonConverterFactory>();
JsonSerializerOptions @mm.PostOptionsVariable = new();
@mm.PostOptionsVariable@{<text/>}.Converters.Add(@mm.PostConverterVariable);
@mm.HttpRequestVariable@{<text/>}.Content = JsonContent.Create(@mm.BodyVariable, typeof(@mm.BodyType), default, @mm.PostOptionsVariable);</text>
}
return _httpConnector.SendAsync(@mm.HttpRequestVariable);
}@* public async ... *@
</text>
}@* @foreach (MethodModel mm in Model.Methods) *@
}
Шаблон для прокси-контроллера
@page
@using Net.Leksi.RestContract
@model Net.Leksi.RestContract.Pages.ControllerProxyModel
//------------------------------
// MVC Controller proxy
// @string.Join(".", new string[] { Model.NamespaceValue, Model.ClassName})
// (Generated automatically)
//------------------------------
@foreach(string usng in Model.Usings)
{
<text>using @usng;
</text>
}
namespace @Model.NamespaceValue;
public class @Model.ClassName: Controller
{
@foreach (MethodModel mm in Model.Methods)
{
foreach(AttributeModel am in mm.Attributes)
{
<text>
[@am.Name@if (am.Properties.Count > 0) {<text>(</text>
string[] keys = am.Properties.Keys.ToArray();
for(int i = 0; i < keys.Length; ++i)
{
if(i > 0){<text>, </text>}
if(!string.IsNullOrEmpty(keys[i])){<text>@keys[i] = </text>}<text>@Html.Raw(am.Properties[keys[i]])</text>
}
<text>)</text> }]</text>
}
<text>
public async @mm.Type @mm.Name@{<text/>}(@for(int i = 0; i < mm.Parameters.Count; ++i)
{
if(i > 0){<text>, </text>}
<text>@mm.Parameters[i].Type @mm.Parameters[i].Name</text>
})
{@if(mm.HasSerialized)
{
<text>
DtoJsonConverterFactory @mm.GetConverterVariable = HttpContext.RequestServices.GetRequiredService<DtoJsonConverterFactory>();
JsonSerializerOptions @mm.GetOptionsVariable = new();
@mm.GetOptionsVariable@{<text/>}.Converters.Add(@mm.GetConverterVariable);</text>
foreach(Tuple<string, string, string> tuple in mm.Deserializing)
{
if(tuple.Item3 is null)
{
<text>
@tuple.Item1 @tuple.Item2 = await HttpContext.Request.ReadFromJsonAsync<@tuple.Item1>(@mm.GetOptionsVariable);</text>
}
else
{
<text>
@tuple.Item1 @tuple.Item2 = JsonSerializer.Deserialize<@tuple.Item1>(@tuple.Item3, @mm.GetOptionsVariable);</text>
}
}
} @* @if(mm.HasSerialized) *@
Controller @mm.ControllerVariable = (Controller)HttpContext.RequestServices.GetRequiredService<@mm.ControllerInterfaceClassName>();
@mm.ControllerVariable@{<text/>}.ControllerContext = ControllerContext;
await ((@mm.ControllerInterfaceClassName)@mm.ControllerVariable).@mm.Name@{<text/>}(@for(int i = 0; i < mm.ControllerParameters.Count; ++i)
{
if(i > 0){<text>, </text>}
<text>@mm.ControllerParameters[i]</text>
});
}@* public async ... *@
</text>
}@* @foreach (MethodModel mm in Model.Methods) *@
}
Шаблон для интерфейса контроллера
@page
@using Net.Leksi.RestContract
@model Net.Leksi.RestContract.Pages.ControllerInterfaceModel
//------------------------------
// MVC Controller interface
// @string.Join(".", new string[] { Model.NamespaceValue, Model.ClassName})
// (Generated automatically)
//------------------------------
@foreach(string usng in Model.Usings)
{
<text>using @usng;
</text>
}
namespace @Model.NamespaceValue;
public interface @Model.ClassName
{
@foreach (MethodModel mm in Model.Methods)
{
<text> @mm.Type @mm.Name</text><text>(</text>
@for(int i = 0; i < mm.Parameters.Count; ++i)
{
if(i > 0)
{
<text>, </text>
}
<text>@mm.Parameters[i].Type @mm.Parameters[i].Name</text>
}
<text>);
</text>
}
}
Инкапсуляция RazorPages в консольное приложение
Напишем простой класс, который будет делать это в принципе, без привязки к конкретной задаче.
public static class Generator
{
...
public static async IAsyncEnumerable<KeyValuePair<string, object>>
Generate(IEnumerable<object> requisite, IEnumerable<string> requests)
{
...
}
Здесь IEnumerable<object> requisite
- всё, что понадобится внутри. Конкретно object
может быть:
Экземпляр класса, который содержит информацию или предоставляет доступ к информации, необходимой какой-либо страничной модели. Этот объект будет зарегистрирован в службах внедрения зависимости как
ServiceLifetime.Singleton
под своим типом.KeyValuePair<Type, object>
, гдеValue
- экземпляр, как описано в предыдущем пункте, который будет зарегистрирован какServiceLifetime.Singleton
под типомKey
.Type
- будет зарегистрирован какServiceLifetime.Transient
.KeyValuePair<Type, Type>
-Value
будет зарегистрирован какServiceLifetime.Transient
под типомKey
.Assembly
- сборка, содержащая в папке Pages сами страницы с моделями. Если другие объекты из предыдущих пунктов содержатся в этой сборке, то её можно не добавлять.
IEnumerable<string> requests
- последовательность запросов к страницам, обычные пути. Можно при желании в зависимости от данных или предыдущих ответов решать, какой будет следующий запрос. Именно поэтому мы не используем здесь коллекцию или массив. То же самое с реквизитом, но он считывается до запросов, так что может зависеть только от исходных данных.
Возвращаемое значение IAsyncEnumerable<KeyValuePair<string, object>>
- пары, где Key
- строка запроса, а Value
- либо строка содержимое ответа, либо Exception
, если что-то пошло не так при выполнении этого запроса. Сам метод не падает, вызывающий код решает, что с этим делать.
Код класса находится здесь
using Microsoft.AspNetCore.Diagnostics;
using System.Net.NetworkInformation;
using System.Reflection;
namespace Net.Leksi.DocsRazorator;
public static class Generator
{
private const string SecretWordHeader = "X-Secret-Word";
private const int MaxTcpPort = 65535;
private const int StartTcpPort = 5000;
public static async IAsyncEnumerable<KeyValuePair<string, object>> Generate(IEnumerable<object> requisite,
IEnumerable<string> requests)
{
ManualResetEventSlim appStartedGate = new();
WebApplication app = null!;
HttpClient client = null!;
string secretWord = Guid.NewGuid().ToString();
Exception? razorPageException = null;
appStartedGate.Reset();
Task loadTask = Task.Run(() =>
{
int port = MaxTcpPort + 1;
List<Assembly> assemblies = new();
List<object> services = new();
foreach (object obj in requisite)
{
if (obj is Assembly asm)
{
if (!assemblies.Contains(asm))
{
assemblies.Add(asm);
}
}
else if (obj is KeyValuePair<Type, Type> typeTypePair)
{
Assembly assembly = typeTypePair.Value.Assembly;
if (!assemblies.Contains(assembly))
{
assemblies.Add(assembly);
}
if (services.Find(v => v is KeyValuePair<Type, object> p && p.Key == typeTypePair.Key
&& Object.ReferenceEquals(p.Value, typeTypePair.Value)) is null)
{
services.Add(obj);
}
}
else if (obj is KeyValuePair<Type, object> typeObjectPair)
{
Assembly assembly = typeObjectPair.Value.GetType().Assembly;
if (!assemblies.Contains(assembly))
{
assemblies.Add(assembly);
}
if (services.Find(v => v is KeyValuePair<Type, object> p && p.Key == typeObjectPair.Key
&& Object.ReferenceEquals(p.Value, typeObjectPair.Value)) is null)
{
services.Add(obj);
}
}
else if (obj is Type type)
{
Assembly assembly = type.Assembly;
if (!assemblies.Contains(assembly))
{
assemblies.Add(assembly);
}
if (!services.Contains(obj))
{
services.Add(obj);
}
}
else
{
Assembly assembly = obj.GetType().Assembly;
if (!assemblies.Contains(assembly))
{
assemblies.Add(assembly);
}
if (!services.Contains(obj))
{
services.Add(obj);
}
}
}
while (true)
{
IPGlobalProperties ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
int[] usedPorts = ipGlobalProperties.GetActiveTcpConnections()
.Select(v => v.LocalEndPoint.Port).Where(v => v >= StartTcpPort).OrderBy(v => v).ToArray();
for (int i = 1; i < usedPorts.Length; ++i)
{
if (usedPorts[i] > usedPorts[i - 1] + 1)
{
port = usedPorts[i] - 1;
break;
}
}
if (port > MaxTcpPort)
{
try
{
throw new Exception("No TCP port is available.");
}
finally
{
appStartedGate.Set();
}
}
WebApplicationBuilder builder = WebApplication.CreateBuilder(new string[] { });
builder.Logging.ClearProviders();
builder.Services.AddRazorPages();
IMvcBuilder mvcBuilder = builder.Services.AddControllersWithViews();
foreach (Assembly assembly in assemblies)
{
mvcBuilder.AddApplicationPart(assembly);
}
foreach (object obj in services)
{
if(obj is KeyValuePair<Type, Type> typeTypePair)
{
builder.Services.AddTransient(typeTypePair.Key, typeTypePair.Value);
}
else if (obj is KeyValuePair<Type, object> typeObjectPair)
{
builder.Services.AddSingleton(typeObjectPair.Key, op =>
{
return typeObjectPair.Value;
});
}
else if(obj is Type type)
{
builder.Services.AddTransient(type);
}
else
{
builder.Services.AddSingleton(obj.GetType(), op =>
{
return obj;
});
}
}
app = builder.Build();
app.UseExceptionHandler(eapp =>
{
eapp.Run(async context =>
{
var exceptionHandlerPathFeature =
context.Features.Get<IExceptionHandlerPathFeature>();
razorPageException = exceptionHandlerPathFeature?.Error;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
});
});
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey(SecretWordHeader) || !context.Request.Headers[SecretWordHeader].Contains(secretWord))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync(String.Empty);
}
else
{
await next.Invoke(context);
}
});
app.MapRazorPages();
app.Lifetime.ApplicationStarted.Register(() =>
{
appStartedGate.Set();
});
app.Urls.Clear();
app.Urls.Add($"http://localhost:{port}");
try
{
app.Run();
break;
}
catch (IOException ex) { }
}
});
appStartedGate.Wait();
if (loadTask.IsFaulted)
{
throw loadTask.Exception;
}
client = new HttpClient();
client.BaseAddress = new Uri(app.Urls.First());
client.DefaultRequestHeaders.Add(SecretWordHeader, secretWord);
foreach (string request in requests)
{
razorPageException = null;
HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, request);
HttpResponseMessage response = await client.SendAsync(requestMessage);
if(razorPageException is { })
{
yield return new KeyValuePair<string, object>(request, razorPageException);
}
else if (response.IsSuccessStatusCode)
{
yield return new KeyValuePair<string, object>(request, await response.Content.ReadAsStringAsync());
}
}
await app.StopAsync();
}
}
Посмотрим, что происходит
{
ManualResetEventSlim appStartedGate = new();
WebApplication app = null!;
HttpClient client = null!;
string secretWord = Guid.NewGuid().ToString();
Exception? razorPageException = null;
appStartedGate.Reset();
...
}
Ворота ManualResetEventSlim appStartedGate
откроются, когда загрузится локальный сервер WebApplication app
. HttpClient client
будет осуществлять запросы. string secretWord
будем передавать в заголовках запросов, чтобы не пускать посторонних. В Exception? razorPageException
будем искать Exception
, если таковой возник при обработке запроса.
Запускаем WebApplication app
...
{
...
// Задача запускает WebApplication
Task loadTask = Task.Run(() =>
{
int port = MaxTcpPort + 1;
List<Assembly> assemblies = new();
List<object> services = new();
foreach (object obj in requisite)
{
// Сортируем реквизиты
...
}
while (true)
{
// Ищем свободный TCP порт
IPGlobalProperties ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties();
int[] usedPorts = ipGlobalProperties.GetActiveTcpConnections()
.Select(v => v.LocalEndPoint.Port).Where(v => v >= StartTcpPort).OrderBy(v => v).ToArray();
for (int i = 1; i < usedPorts.Length; ++i)
{
if (usedPorts[i] > usedPorts[i - 1] + 1)
{
port = usedPorts[i] - 1;
break;
}
}
if (port > MaxTcpPort)
{
try
{
throw new Exception("No TCP port is available.");
}
finally
{
appStartedGate.Set();
}
}
// Конфигурируем WebApplication
WebApplicationBuilder builder = WebApplication.CreateBuilder(new string[] { });
builder.Logging.ClearProviders();
builder.Services.AddRazorPages();
// Добавляем сборки со страницами
IMvcBuilder mvcBuilder = builder.Services.AddControllersWithViews();
foreach (Assembly assembly in assemblies)
{
mvcBuilder.AddApplicationPart(assembly);
}
foreach (object obj in services)
{
// Регистрируем объекты и типы
...
}
app = builder.Build();
// При исключении заносим его в razorPageException и возвращаем
// StatusCodes.Status500InternalServerError
app.UseExceptionHandler(eapp =>
{
eapp.Run(async context =>
{
var exceptionHandlerPathFeature =
context.Features.Get<IExceptionHandlerPathFeature>();
razorPageException = exceptionHandlerPathFeature?.Error;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
});
});
// Отсекаем запросы не от нас
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey(SecretWordHeader) || !context.Request.Headers[SecretWordHeader].Contains(secretWord))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync(String.Empty);
}
else
{
await next.Invoke(context);
}
});
app.MapRazorPages();
// Открываем ворота appStartedGate, когда готовы обрабатывать запросы
app.Lifetime.ApplicationStarted.Register(() =>
{
appStartedGate.Set();
});
// Пытаемся запуститься на выбранном порту,
// но если ВДРУГ он уже занят, повторяем попытку с другим
app.Urls.Clear();
app.Urls.Add($"http://localhost:{port}");
try
{
app.Run();
break;
}
catch (IOException ex) { }
}
});
...
}
И начинаем слать запросы
{
...
// Ждём сигнал на старт
appStartedGate.Wait();
// Если приложение не смогло запуститься, валимся
if (loadTask.IsFaulted)
{
throw loadTask.Exception;
}
// Настраиваем клиента
client = new HttpClient();
client.BaseAddress = new Uri(app.Urls.First());
client.DefaultRequestHeaders.Add(SecretWordHeader, secretWord);
// Шлём запросы
foreach (string request in requests)
{
// убираем прошлое исключение
razorPageException = null;
HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Get, request);
HttpResponseMessage response = await client.SendAsync(requestMessage);
if(razorPageException is { })
{
// Если получили исключение, возвращаем исключение
yield return new KeyValuePair<string, object>(request, razorPageException);
}
else
{
// возвращаем результат
yield return new KeyValuePair<string, object>(request, await response.Content.ReadAsStringAsync());
}
}
// Запросы кончились, занавес
await app.StopAsync();
}
Использование для нашей задачи
Создадим проект:
Удалим всё ненужное, добавим нужное:
Модели у нас одинаковые, только строятся по разному, поэтому их унаследуем от BasePageModel:
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Net.Leksi.RestContract;
public class BasePageModel: PageModel
{
internal string NamespaceValue { get; set; }
internal string ClassName { get; set; }
internal List<string> Usings { get; set; }
internal List<MethodModel> Methods { get; set; }
}
Сами модели заполняем извне с помощью билдера, полученного из внедрения зависимостей.
using Microsoft.AspNetCore.Mvc;
namespace Net.Leksi.RestContract.Pages;
public class ConnectorBaseModel : BasePageModel
{
public void OnGet([FromServices] IConnectorBaseBuilder builder)
{
builder.BuildConnectorBase(this);
}
}
using Microsoft.AspNetCore.Mvc;
namespace Net.Leksi.RestContract.Pages;
public class ControllerInterfaceModel : BasePageModel
{
public void OnGet([FromServices] IControllerInterfaceBuilder builder)
{
builder.BuildControllerInterface(this);
}
}
using Microsoft.AspNetCore.Mvc;
namespace Net.Leksi.RestContract.Pages
{
public class ControllerProxyModel : BasePageModel
{
public void OnGet([FromServices] IControllerProxyBuilder builder)
{
builder.BuildControllerProxy(this);
}
}
}
Точкой входа является метод класса HelpersBuilder, который реализует все три интерфейса, которые ждут модели и сам является реквизитом:
public class HelpersBuilder : IControllerInterfaceBuilder, IControllerProxyBuilder, IConnectorBaseBuilder
{
...
public async Task BuildHelpers<TConnector>(string controllerInterfaceFullName, string controllerProxyFullName,
string connectorBaseFullName)
{
CollectRequisites<TConnector>(controllerInterfaceFullName, controllerProxyFullName, connectorBaseFullName);
await foreach (KeyValuePair<string, object> result in Generator.Generate(
new object[] {
new KeyValuePair<Type, object>(typeof(IConnectorBaseBuilder), this),
new KeyValuePair<Type, object>(typeof(IControllerInterfaceBuilder), this),
new KeyValuePair<Type, object>(typeof(IControllerProxyBuilder), this)
},
new string[] {
"ConnectorBase",
"ControllerInterface",
"ControllerProxy",
}
))
{
if (result.Value is Exception)
{
Console.WriteLine($"// {result.Key}");
}
Console.WriteLine(result.Value);
}
}
...
public void BuildConnectorBase(ConnectorBaseModel model)
{
...
}
...
public void BuildControllerProxy(ControllerProxyModel model)
{
...
}
...
public void BuildControllerInterface(ControllerInterfaceModel model)
{
...
}
...
private void CollectRequisites<TConnector>(string controllerFullName,
string proxyFullName, string connectorBaseFullName)
{
...
}
...
}
Запустим в виде юнит-теста опять же для простоты
public class RestContract
{
private static IHost _host;
static RestContract()
{
_host = Host.CreateDefaultBuilder()
.ConfigureServices(serviceCollection =>
{
...
serviceCollection.AddTransient<HelpersBuilder>();
}).Build();
Trace.Listeners.Add(new ConsoleTraceListener());
Trace.AutoFlush = true;
}
[Test]
public async Task BuildRestHelpers()
{
HelpersBuilder codeGenerator = _host.Services.GetRequiredService<HelpersBuilder>();
await codeGenerator.BuildHelpers<IConnector>(
"DtoKit.Demo.IDemoController", "DtoKit.Demo.DemoControllerProxy",
"DtoKit.Demo.DemoConnectorBase");
}
}
Разложим типы по файлам и разместим в сервере и клиенте
Все исходники:
https://github.com/Leksiqq/DocsRazorator/tree/v1.0.0/Library
https://github.com/Leksiqq/RestContract/tree/v1.0.0/HelpersBuilder
Комментарии (9)
mayorovp
07.05.2022 00:23+2Я правильно понял, что вы поднимаете локальный сервер и обращаетесь к нему только для того, чтобы воспользоваться Razor Pages как шаблонизатором?.. Зачем так сложно-то всё?
Из этой связки нужно выкинуть, как минимум, веб-сервер. Это довольно просто:
public static RequestDelegate CreateRequestDelegate() { var host = Host.CreateDefaultBuilder() .ConfigureWebHost(builder => builder.ConfigureServices(services => { // тут делается Startup.ConfigureServices })) .ConfigureServices(services => { foreach (var sd in services.Where(sd => sd.ServiceType == typeof(IHostedService)).ToArray()) services.Remove(sd); }) .Build(); var features = new FeatureCollection(); var app = host.Services.GetRequiredService<IApplicationBuilderFactory>().CreateBuilder(features); // тут делается Startup.Configure return app.Build(); }
Всё, как только у нас есть RequestDelegate — ему можно скармливать настроенные экземпляры DefaultHttpContext:
var rd = CreateRequestDelegate(); var ctx = new DefaultHttpContext(); ctx.Request.Protocol = "HTTP/1.0"; ctx.Request.Method = "GET"; ctx.Request.Scheme = "http"; ctx.Request.Path = "/"; ctx.Request.PathBase = "/"; ctx.Response.Body = Console.OpenStandardOutput(); await rd(ctx);
Не нужно никаких поисков свободного порта, никаких секретных заголовков. Даже запускать и останавливать ничего не требуется! При желании можно даже создать HttpMessageHandler на основе RequestDelegate и обернуть его в HttpClient.
А если копать дальше — то можно и RequestDelegate тут выкинуть, со всей маршрутизацией и хостом.
mvv-rus
07.05.2022 21:33var ctx = new DefaultHttpContext();
Боюсь, этого будет недостаточно: не будет работать доступ из контекста к контейнеру сервисов (он же — DI-container) через свойство HttpContext.RequestServices. А контейнер сервисов для работы приложения с Razor Pages, как пить дать, нужен.
Свойство это по умолчанию реализуется (если игнорировать кэширование, ибо кэш по первому разу все равно надо заполнить) через IServiceProviderFactory, который присваивается свойству DefaultHttpContext.ServiceProviderFactory в DefaultHttpContextFactory.Initialize (а исходно это свойство равно null). Это нужно для того, чтобы поддерживать сервисы с временем жизни ограниченной области (Scoped) на время обработки одного запроса.
Поскольку в консольном приложении ограниченные области не нужны, то, возможно, этому свойству будет достаточно присвоить IServiceProvider самого контейнера сервисов:ctx.RequestServices=host.Services;
Но вообще-то DefaultHttpContext инициализуется ещё и через содержимое параметра типа IFeatureCollection (в конструкторе или через Initialize()), и я не берусь сказать, что в эту коллекцию нужно добавить обязательно, а что — можно не добавлять.
А ещё DefaultHttpContextFactory.Initialize отдельно инициализует в DefaultHttpContext свойство HttpContext.FormOptions одноименного типа (оно передается с использованием options pattern, конкретно — через IOptions<FormOptions>.Value ). Где и как оно используется в веб-приложении — без понятия.mayorovp
07.05.2022 23:42Да, спасибо за уточнения. Все нужные ему фичи DefaultHttpContext умеет создавать сам по требованию, кроме двух — IHttpRequestFeature и IHttpResponseFeature. Точнее, эти две он тоже умеет создавать, но только при создании через конструктор без параметров.
Вот этот код у меня заработал:
var factory = app.ApplicationServices.GetRequiredService<IHttpContextFactory>(); // … var ctx = factory.Create(new FeatureCollection()); ctx.Features.Set<IHttpRequestFeature>(new HttpRequestFeature()); ctx.Features.Set<IHttpResponseFeature>(new HttpResponseFeature()); // … factory.Dispose(ctx);
Однако, фабрика не обязательна, основные параметры можно и без неё присвоить:
var ctx = new DefaultHttpContext { ServiceScopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>(), FormOptions = app.ApplicationServices.GetRequiredService<IOptions<FormOptions>>().Value, };
Помимо создания контекста, фабрика также настраивает IHttpContextAccessor — но эту штука вряд ли используется самим фреймворком, она больше похожа на костыль для тех кто не умеет scoped-сервисы писать. Опять-таки, если понадобится — настроить недолго.
mvv-rus
08.05.2022 02:05Получилось — именно сделать HTML из .cshml?
Если так, то возьму на заметку.
Но вообще пытаться нетрадиционным образом использовать ASP.NET Core — оно стремно (мне, по крайней мере): там куча зависимостей, и просто так не поймешь, какая из них выстрелить в тебя может.
PS IHttpAccessor — он AFAIK для того, чтобы HttpContext раньше времени не потерялся (не был переиспользован под другой запрос, например). Здесь он явно не нужен.mayorovp
08.05.2022 10:01Получилось — именно сделать HTML из .cshml?
Получилось получить ответ от шаблона пустого приложения. Для cshtml надо ещё с самими Razor Pages разбираться, а мне лень.
PS IHttpAccessor — он AFAIK для того, чтобы HttpContext раньше времени не потерялся (не был переиспользован под другой запрос, например). Здесь он явно не нужен.
Нет, он для обращения к HttpContext из синглтонов.
mvv-rus
08.05.2022 15:11Получилось получить ответ от шаблона пустого приложения. Для cshtml надо ещё с самими Razor Pages разбираться, а мне лень.
Благодарю за информацию.
Так как генрация кода из реального шаблона Razor — это отдельная сложная работа, там может случиться многое. Так что взять на заметку просто так не получится: при случае придется все проверять самостоятельно.Нет, он для обращения к HttpContext из синглтонов.
Меня интересовал не про где используется, про то, что он делает. Посмотрел, что раельно делает реализация (HttpContextAccessor): она сохраняет ссылку на HttpContext внутри ExecutionContext в AsyncLocal, судя по тамошнему комментарию — для корректной очистки всех ссылок на него во всех контекстах выполнения при его очистке.
Вердикт однако остается тем же самым — здесь он явно не нужен.mayorovp
08.05.2022 16:28Тот комментарий относится к использованию HttpContextHolder, а не к использованию AsyncLocal.
nronnie
09.05.2022 15:14А стоит ли вообще это делать с помощью Razor, который заточен под использование в ASP.NET MVC View и WebPages? Для этого есть гораздо более подходящие инструменты, например те же Handlebars.Net, Nustache, Fluid, DotLiquid, etc. На крайний случай есть просто RazorEngine (ныне полумертвый), который просто шаблонный движок и никакого ASP.NET ни в каком виде не требует.
AgentFire
Нормальным людям хочется чтобы XAML в веб превращался, а у вас всё наоборот как-то.