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


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


Введение


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


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

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

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


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


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


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


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


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


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

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


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


Request Routing


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


ActionMethodSelectorAttribute


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


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


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


Conventions


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


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


public abstract class JsonRpcController : ControllerBase {}

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


полезный код в ControllerConvention
public void Apply(ControllerModel controllerModel)
{
    if (!typeof(JsonRpcController).IsAssignableFrom(controllerModel.ControllerType))
    {
        return;
    }

    controllerModel.Selectors.Clear();
    controllerModel.Filters.Insert(0, new ServiceFilterAttribute(typeof(JsonRpcFilter)));
}

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


А вот ActionConvention
public void Apply(ActionModel actionModel)
{
    if (!typeof(JsonRpcController).IsAssignableFrom(actionModel.Controller.ControllerType))
    {
        return;
    }

    actionModel.Selectors.Clear();
    actionModel.Selectors.Add(new SelectorModel()
    {
        AttributeRouteModel = new AttributeRouteModel() {Template = "/api/jsonrpc"},
        ActionConstraints = {new JsonRpcAttribute()}
    });
}

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


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

        // return true если action подходит под запрос, например:
        return request.Method == action.DisplayName;
    }
}

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


Middleware


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


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


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


Parameter Binding


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


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


Разные params


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


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

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

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


Реализация


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


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

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


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

    if (parameterModel.BindingInfo == null)
    {
        parameterModel.BindingInfo = new BindingInfo()
        {
            BinderType = typeof(JsonRpcModelBinder),
            BindingSource = BindingSource.Custom
        };
    }
}

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


Удобства


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


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

public void DoSomething(MyModel model)

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


BindingStyle
public enum BindingStyle { Default, Object, Array }

...

public FromParamsAttribute(BindingStyle bindingStyle)
{
    BindingStyle = bindingStyle;
}

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


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

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

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


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


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


// вот так не получится
{"user_id": 1} => int userId

//  зато можно наоборот и запомнить это в метаданных
int userId => "user_id"

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


Учимся у aspnet


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


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


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


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

Error handling


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


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


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


Binding failed


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


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


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


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


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


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


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

Batch


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


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


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


ID


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


Notification


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


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


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


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


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

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


Configuration


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


Имя метода


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


public enum MethodStyle {ControllerAndAction, ActionOnly}

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


ActionOnly — просто method_name.


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


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


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


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


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


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


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

public class JsonRpcMethodOptions
{    
    public Type RequestSerializer { get; set; }  // пользовательский сериалайзер
    public PathString Route { get; set; }  // маршрут по умолчанию, например /api/jsonrpc
    public MethodStyle MethodStyle { get; set; }  // см. выше
}

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


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

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


Подключаем


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


Startup.cs
services.AddMvc()
    .AddJsonRpcServer()
    .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

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

Контроллер
public class MyController : JsonRpcController
{
    public ObjectResult Foo(object value, bool flag)
    {
        return Ok(flag ? value : null);
    }

    public void BindObject([FromParams(BindingStyle.Object)] MyModel model)
    {
    }

    [Route("/test")]
    public string Test()
    {
        return "test";
    }

    [JsonRpcMethodStyle(MethodStyle.ActionOnly)]
    public void SpecialAction()
    {
    }

    [JsonRpcSerializer(typeof(CamelCaseJsonRpcSerializer))]
    public void CamelCaseAction(int myParam)
    {
    }
}

Клиент


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


HttpClient


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


Batch


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


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


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


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

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


Developer Experience


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


TODO


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


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


Ссылки


Исходники


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


Пакеты в Nuget


Бонус


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