Хотите добиться нестандартного поведения от 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 можно легко понять, стоит ли вмешиваться в метаданные, или нет. Вот так будет выглядеть
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.
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 для параметра. Как раз то, что нужно!
[AttributeUsage(AttributeTargets.Parameter)]
public class FromParamsAttribute : Attribute, IBindingSourceMetadata, IBinderTypeProviderMetadata
{
public BindingSource BindingSource => BindingSource.Custom;
public Type BinderType => typeof(JsonRpcModelBinder);
}
Теперь атрибут придется повесить на каждый параметр. Вернемся к conventions и напишем реализацию IParameterModelConvention
. Только оказывается, что нужно не использовать атрибут, а создавать
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 в объекте, и биндить по порядку. Но из рефлексии этого не достать, такая информация просто не сохраняется. Поэтому массив в объект сбиндить не получится без костылей типа атрибутов с номерами… Но можно сделать проще: сделаем эту фичу опциональной. Да, она не работает с массивами, что ломает поддержку протокола, поэтому придется выбирать. Добавим параметр в наш атрибут:
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!
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. Кому-нибудь интересны подробности? Там местами такая жуть...
mayorovp
Так ведь Endpoint Routing уже отработал...
Потому что один контроллер может быть доступен по нескольким маршрутам.
А что мешает перечислить их в конструкторе? Вроде же BinderTypeModelBinder, который и создаёт ваш биндер, создаёт его через ObjectFactory, которая умеет резолвить зависимости...
Тут у вас что-то страшное написано. Можно же сериализовать/десериализовать "шапку" самому через JsonTextWriter/JsonTextReader, а основному сериализатору оставить параметры.
Rast1234 Автор
имелось в виду решение, когда ER определил какой нужен endpoint и прописал его куда-то в HttpContextFeature, а мы берем и подменяем на свой. Видел такую статью и демо, но что-то не срослось, возможно потому что на 2.2. ER реализован как-то не так, как в 3.1
Действительно, спасибо! *facepalm*
Зарегать его в DI напрямую — не рабоает. Вероятно, я не нашел, как именно создается биндер, уже не помню. Статьи, которые попадались мне, юзали Request Services из контекста.
А про сериализацию честно говоря не придумал как можно было бы проще. Ну, разве что ползать по json-у, но это я считаю идейно неправильным, поэтому использовал newtonsoft json converters везде где только можно)