[Разработка веб-сайтов, .NET, ASP, C#, Микросервисы] Учим ASP.NET Core новым трюкам на примере Json Rpc 2.0
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Хотите добиться нестандартного поведения от 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
SPL
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
SPL
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, который потом вынесем в настройки.
И добавим тот самый атрибут
SPL
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
SPL
[AttributeUsage(AttributeTargets.Parameter)]
public class FromParamsAttribute : Attribute, IBindingSourceMetadata, IBinderTypeProviderMetadata
{
public BindingSource BindingSource => BindingSource.Custom;
public Type BinderType => typeof(JsonRpcModelBinder);
}
Теперь атрибут придется повесить на каждый параметр. Вернемся к conventions и напишем реализацию IParameterModelConvention. Только оказывается, что нужно не использовать атрибут, а создавать
`BindingInfo` с той же информацией
SPL
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
SPL
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, но тогда он будет конфликтовать с существующими. Поэтому придется
резолвить зависимости вручную
SPL
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 в фильтре, там же можно
подставить нужный форматтер
SPL
...
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, но очень удобно. Такое поведение нужно разрешать при необходимости.
Объединяем все вместе
Добавим классы с опциями, чтобы рулить умолчаниями
SPL
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
SPL
services.AddMvc()
.AddJsonRpcServer()
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
// или с опциями
services.AddMvc()
.AddJsonRpcServer(options =>
{
options.DefaultMethodOptions.Route = "/rpc";
options.AllowRawResponses = true;
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
Контроллер
SPL
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 содержит
Разные методы для интерпретации ответа
SPL
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. Кому-нибудь интересны подробности? Там местами такая жуть...
===========
Источник:
habr.com
===========
Похожие новости:
- [Asterisk, Разработка на Raspberry Pi, DIY или Сделай сам] Raspberry Pi + FreeBPX(asterisk) + Mikrotik = АТС мини
- [Java, Микросервисы] Собственный провайдер пользователей для Keycloak
- [Системное администрирование, .NET, DevOps] Обновление процесса CI/CD: год спустя
- [Разработка веб-сайтов, JavaScript, Программирование, TypeScript] Совет #1 по ознакомлению с новыми кодовыми базами JavaScript (перевод)
- [Java, .NET] «Microsoft Coffee»: первоапрельский ответ на Java
- [.NET, C#] Страсти по Serilog + .NET Core: Глобальный логгер
- [Разработка игр, C#, Прототипирование, Дизайн игр, Игры и игровые приставки] Tantramantra и магия проектирования
- [Python, Разработка робототехники, Робототехника] Сделать робота на raspberry pi, обновленный pi-tank. Часть 2. Софт
- [Разработка веб-сайтов, CSS, Программирование, HTML] Вы можете создавать эти элементы, не используя JavaScript (перевод)
- [Разработка на Raspberry Pi, Компьютерное железо] Умелец показал, как на треть уменьшить Raspberry Pi Pico обычным лобзиком
Теги для поиска: #_razrabotka_vebsajtov (Разработка веб-сайтов), #_.net, #_asp, #_c#, #_mikroservisy (Микросервисы), #_c#, #_asp.net_core, #_.net, #_jsonrpc, #_webprogrammirovanie (web-программирование), #_nenormalnoe_programmirovanie (ненормальное программирование), #_grabli (грабли), #_razrabotka_vebsajtov (
Разработка веб-сайтов
), #_.net, #_asp, #_c#, #_mikroservisy (
Микросервисы
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 01:04
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Хотите добиться нестандартного поведения от 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. Зачем все это? Я .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 можно легко понять, стоит ли вмешиваться в метаданные, или нет. Вот так будет выглядеть полезный код в ControllerConventionSPLpublic 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. А вот ActionConventionSPLpublic 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, который потом вынесем в настройки. И добавим тот самый атрибутSPLclass 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 для параметра. Как раз то, что нужно! Пишем свой FromParamsAttributeSPL[AttributeUsage(AttributeTargets.Parameter)]
public class FromParamsAttribute : Attribute, IBindingSourceMetadata, IBinderTypeProviderMetadata { public BindingSource BindingSource => BindingSource.Custom; public Type BinderType => typeof(JsonRpcModelBinder); } Теперь атрибут придется повесить на каждый параметр. Вернемся к conventions и напишем реализацию IParameterModelConvention. Только оказывается, что нужно не использовать атрибут, а создавать `BindingInfo` с той же информациейSPLpublic 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 в объекте, и биндить по порядку. Но из рефлексии этого не достать, такая информация просто не сохраняется. Поэтому массив в объект сбиндить не получится без костылей типа атрибутов с номерами… Но можно сделать проще: сделаем эту фичу опциональной. Да, она не работает с массивами, что ломает поддержку протокола, поэтому придется выбирать. Добавим параметр в наш атрибут: BindingStyleSPLpublic 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, но тогда он будет конфликтовать с существующими. Поэтому придется резолвить зависимости вручнуюSPLpublic 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 в фильтре, там же можно подставить нужный форматтерSPL...
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, но очень удобно. Такое поведение нужно разрешать при необходимости. Объединяем все вместе Добавим классы с опциями, чтобы рулить умолчаниямиSPLpublic 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; } // см. выше } И атрибуты, чтобы умолчания переопределять:
Для роутинга свой атрибут не нужен, все будет работать со стандартным [Route]. Подключаем Пример кода, который использует разные фичи. Важно заметить, что это никак не мешает обычному коду на aspnet! Startup.csSPLservices.AddMvc()
.AddJsonRpcServer() .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); // или с опциями services.AddMvc() .AddJsonRpcServer(options => { options.DefaultMethodOptions.Route = "/rpc"; options.AllowRawResponses = true; }) .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); КонтроллерSPLpublic 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 содержит Разные методы для интерпретации ответаSPLT 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. Кому-нибудь интересны подробности? Там местами такая жуть... =========== Источник: habr.com =========== Похожие новости:
Разработка веб-сайтов ), #_.net, #_asp, #_c#, #_mikroservisy ( Микросервисы ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 23-Ноя 01:04
Часовой пояс: UTC + 5