Свой JsonRPC на ASP.NET Core с нуля
Дмитрий Бахтенков
Введение
В предыдущей статье мы говорили о разных типах архитектуры HTTP API. В этой статье мы реализуем JsonRpc с помощью фреймворка ASP.NET Core
Для начала, вспомним основы.
Общее определение RPC звучит так:
RPC - (Remote Procedure Call, удалённый вызов процедур) — класс технологий, позволяющих программам вызывать функции или процедуры в другом адресном пространстве (на удалённых узлах, либо в независимой сторонней системе на том же узле)
В реализации JsonRpc мы используем протокол HTTP: будет реализован единый эндпоинт POST api/{service-name}/execute, который принимает следующее тело запроса:
{
"jsonrpc": "2.0", // версия протокола
"method": "", // название функции, которую необходимо выполнить
"params": {
"param1": "value",
"param2": "value"
}
}
Формулируем задачу
Нам необходимо:
- HTTP API с одним эндпоинтом
- Возможность динамического выполнения метода на основе названия сервиса и метода из запроса
Разработка

Общая архитектура
Основные классы в системе проще всего описать на диаграмме

- Интерфейс
IRpcServiceнеобходим для обобщения всех сервисов, которые могут использоваться в эндпоинте RPC. Этот интерфейс должны реализовать сервисы, методы которых будут вызываться через RPC. - Класс
RpcAttributeнеобходим для кастомизации названия сервиса - например, если класс называетсяUserAuthorizationService, то в запросе необходимо будет указать его длинное название. В атрибуте можно указать[Rpc(”UserAuth”)], и тогда запрос будетapi/UserAuth/execute RpcServiceHolderявляется хранилищем всех rpc-сервисов. С помощью словаря он позволяет удобно получать реализацию сервиса по его названию. С помощью механизма Dependency Injection мы получаем массив всех реализацийIRpcService, а затем немного преобразуем его в словарь, чтобы получать реализации по названию-ключу- Класс
RpcExecutorотвечает за выполнение конкретного метода, который мы передаём в теле запроса. С помощью механизма рефлексии он вытаскивает метод из класса-сервиса по названию, собирает все необходимые параметры, и если всё верно, то выполняет этот метод и возвращает его результат.
Абстракции
Я создал решение, куда добавил два проекта: ASP.NET Core Web API и xUnit Test Project. По умолчанию структура проектов выглядит вот так:

Для начала удалим лишнее и определим общую структуру проекта: добавим папки Core для логики RPC и Services для пользовательских сервисов. В папку Core добавим ещё две - Abstractions и Implementations. Получилась следующая структура проекта:

В папку Abstractions добавим пустой интерфейс IRpcService, а также класс атрибута RpcAttribute:
[AttributeUsage(AttributeTargets.Class)]
public class RpcAttribute : Attribute
{
public RpcAttribute(string name)
{
Name = name;
}
public string Name { get; }
}
С помощью этого атрибута будем кастомизировать названия RPC-сервисов.
Далее добавим модели RpcInput и RpcResult:
namespace JsonRpc.Core.Models;
public class RpcInput
{
public string Method { get; set; }
public JsonDocument Params { get; set; }
}
public class RpcResult
{
public string Method { get; set; }
public object Result { get; set; }
public int Status { get; set; }
}
А также определим интерфейсы IRpcServiceHolder и IRpcExecutor:
public interface IRpcExecutor
{
public Task<RpcResult> ExecuteMethod(string service, RpcInput input);
}
public interface IRpcServiceHolder
{
public IRpcService GetService(string input);
}
Реализация
Механизм динамического вызова метода по названию мы реализуем с помощью рефлексии.
Механизм рефлексии позволяет получать объекты (типа Type), которые описывают сборки, модули и типы. Отражение можно использовать для динамического создания экземпляра типа, привязки типа к существующему объекту, а также получения типа из существующего объекта и вызова его методов или доступа к его полям и свойствам. Если в коде используются атрибуты, отражение обеспечивает доступ к ним.В папку Core.Implementations добавим класс RpcServiceHolder. В конструкторе класса будем формировать словарь на основе названия сервисов, а в качестве значения в словаре будет сама реализация IRpcService.
public class RpcServiceHolder : IRpcServiceHolder
{
private readonly Dictionary<string, IRpcService> _rpcServicesDict;
// получаем из DI-контейнера массив реализаций IRpcService
public RpcServiceHolder(IEnumerable<IRpcService> rpcServices)
{
// формируем словарь `название сервиса -> сервис` для удобного
// получения реализаций по названию
_rpcServicesDict = rpcServices.Select(x =>
{
// получаем тип конкретного сервиса для использования рефлексии
var type = x.GetType();
// получаем атрибут RpcAttribute
var attr = type.GetCustomAttribute<RpcAttribute>();
// если на сервис назначен атрибут, берём название оттуда,
// иначе просто название типа
var key = attr is not null ? attr.Name : type.Name;
//возвращаем две переменные для построения словаря - ключ и значение
return (Key: key.ToLower(), Value: x);
}).ToDictionary(x => x.Key, x => x.Value);
}
public IRpcService GetService(string input)
{
}
}
Далее реализуем метод GetService - он должен возвращать реализацию сервиса по названию. Просто будем брать её из словаря, который подготовлен в конструкторе, а если такого сервиса нет - выкинем исключение.
public IRpcService GetService(string input)
{
var lower = input.ToLower();
if (_rpcServicesDict.ContainsKey(lower))
{
return _rpcServicesDict[lower];
}
throw new Exception("Service not found");
}
Теперь перейдём к реализации RpcExecutor. Также добавим класс в папку Implementations, а затем добавим приватное поле IRpcServiceHolder и пробросим его в конструктор:
public class RpcExecutor : IRpcExecutor
{
private readonly IRpcServiceHolder _rpcServiceHolder;
public RpcExecutor(IRpcServiceHolder rpcServiceHolder)
{
_rpcServiceHolder = rpcServiceHolder;
}
public async Task<RpcResult> ExecuteMethod(string service, RpcInput input)
{
}
}
Приступим к реализации метода ExecuteMethod. Для начала нам необходимо получить реализацию сервиса по названию:
var rpcService = _rpcServiceHolder.GetService(service);
С помощью рефлексии мы можем получить информацию об определённом методе по его названию. Для этого сначала необходимо получить информацию о типе, где мы будем искать метод, а затем выполнить метод GetMethod():
var method = rpcService.GetType().GetMethod(input.Method);
if (method is null)
{
throw new MissingMethodException();
}
Если информация о методе не найдена, выбросим исключение.
Далее необходимо получить информацию о параметрах метода, который мы будем выполнять. После получения параметров мы преобразуем их в удобный словарь для дальнейшей работы с ними:
var parameters = method
.GetParameters()
.ToDictionary(x => x.Name!, x => x.ParameterType);
var parameterKeys = parameters.Keys.ToArray();
Если параметров нет, то мы можем сразу вызвать этот метод. Для этого я написал функцию InvokeMethod, его реализацию мы рассмотрим чуть позже. Он принимает на вход информацию о методе, реализацию сервиса и массив параметров.
if (!parameters.Any())
{
return await InvokeMethod(method, rpcService, Array.Empty<Object>());
}
Если параметры есть, нам необходимо получить массив их значений на основе входных данных. Для этого получим словарь (да, автор любит словари) из параметров, переданных нам в запросе, добавим массив для заполнения, и для каждого параметра получим объект с его значением:
// преобразуем параметры, которые мы получили в запросе, в словарь
var paramsDict = input.Params.Deserialize<Dictionary<string, JsonElement>>()!;
// создаём массив для значений параметров
var parametersArray = new object?[parameters.Count];
foreach (var p in paramsDict)
{
// для каждого параметра, который ожидает метод, получаем значение
// и конвертируем его
// строковое представление с помощью класса Convert
if (parameters.ContainsKey(p.Key))
{
var type = parameters[p.Key];
parametersArray[Array.IndexOf(parameterKeys, p.Key)]
= Convert.ChangeType(p.Value.ToString(), type);
}
}
// вызываем метод с подготовленными параметрами
return await InvokeMethod(method, rpcService, parametersArray);
Теперь разберём метод InvokeMethod. Любой метод в сервисе может возвращать четыре вида ответа:
- void - ничего не возвращает
- Task - асинхронный метод, который ничего не возвращает
- Task<T> - асинхронный метод, возвращающий какой-то объект с типом T
- T - обычный метод, возвращающий какой-то объект с типом T
Начнём с асинхронных методов. Если просто Task - мы должны получить объект задачи с помощью метода Invoke, а затем выполнить await для задачи:
if (method.ReturnType == typeof(Task))
{
var task = (Task)method.Invoke(service, parametersArray)!;
await task;
return new RpcResult
{
Method = method.Name,
Status = 200
};
}
С асинхронным методом, который возвращает какой-то объект, почти то же самое, только после выполнения задачи мы должны получить свойство Result объекта Task:
else if (method.ReturnType.IsGenericType
&& method.ReturnType.BaseType == typeof(Task))
{
var task = (Task)method.Invoke(service, parametersArray)!;
await task;
return new RpcResult
{
Method = method.Name,
Result = task.GetType().GetProperty("Result")!.GetValue(task),
Status = 200
};
}
Для синхронных методов всё проще - мы должны просто выполнить метод Invoke и забрать результат, если это необходимо:
else if (method.ReturnType == typeof(void))
{
method.Invoke(service, parametersArray);
return new RpcResult
{
Method = method.Name,
Status = 200
};
}
else
{
var result = method.Invoke(service, parametersArray);
return new RpcResult
{
Method = method.Name,
Result = result,
Status = 200
};
}
Теперь необходимо зарегистрировать наши сервисы в DI-контейнере и добавить один эндпоинт для вызова методов. Файл Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// добавляем наши сервисы в DI-контейнер
builder.Services
.AddScoped<IRpcServiceHolder, RpcServiceHolder>()
.AddScoped<IRpcExecutor, RpcExecutor>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
// с помощью механизма minimal api создаём эндпоинт для вызова rpc
app.MapPost("api/{service}/execute", async (
string service,
[FromServices] IRpcExecutor rpcExecutor,
[FromBody] RpcInput input)
=> await rpcExecutor.ExecuteMethod(service, input));
app.Run();
Тестирование
Чтобы проверить наше решение, необходимо создать тестовый rpc-сервис - некий класс, который будет выполнять какие-то действия. В папке Services я создал класс TestRpcService:
public class TestRpcService : IRpcService
{
public Task<int> Add(int a, int b)
{
return Task.FromResult(a + b);
}
}
Далее необходимо зарегистрировать его в DI-контейнере. Можно указать класс как простую имплементацию интерфейса IRpcService, но тогда не получится использовать класс напрямую, вне логики rpc. Поэтому сначала мы регистрируем просто класс, а для интерфейса указываем, что нужно вернуть объект исходного класса:
builder.Services
.AddScoped<TestRpcService>()
.AddScoped<IRpcService, TestRpcService>(
x => x.GetRequiredService<TestRpcService>()
);
Теперь, когда мы попытаемся внедрить через конструктор IRpcService (или IEnumerable<IRpcService>), нам вернётся реализация TestRpcService. Таким образом можно выполнять множественную регистрацию - когда интерфейс IRpcService реализует множество классов.
Теперь можно запустить приложение. Мы протестируем его через интерфейс Swagger:

Результат выполнения метода:

Для закрепления материала и проверки всех вариантов вызова метода попробуйте добавить больше методов и/или сервисов и вызвать их таким способом.
Юнит-тесты
Теперь напишем юнит-тесты для наших кейсов. Юнит-тест представляет собой метод, которая проверяет определённый функционал определённого модуля. В нашем случае проверять будем RpcExecutor.
Каждый юнит-тест состоит из трёх этапов:
- Arrange - подготовка данных
- Act - выполнение действия
- Assert - проверка результата
Я создал проект xUnit Test Project, затем создал два класса: RpcExecutorTests - для тестов и TestRpcService - сервис, вызов методов которого мы будем проверять.
public class TestRpcService : IRpcService
{
public int Sum { get; private set; }
public int Add(int a, int b)
{
return a + b;
}
public Task<int> AddAsync(int a, int b)
{
return Task.FromResult(a + b);
}
public void AddInternal(int a, int b)
{
Sum = a + b;
}
public Task AddInternalAsync(int a, int b)
{
Sum = a + b;
return Task.CompletedTask;
}
}
Класс RpcExecutorTests:
public class RpcExecutorTests
{
private readonly IRpcExecutor _rpcExecutor;
private readonly TestRpcService _testRpcService;
public RpcExecutorTests()
{
_testRpcService = new TestRpcService();
var holder = new RpcServiceHolder(new[] { _testRpcService });
_rpcExecutor = new RpcExecutor(holder);
}
private readonly string _paramsJson =
JsonSerializer.Serialize(new Dictionary<string, object>
{
{ "a", 1 },
{ "b", 2 }
});
}
Пример тестового метода:
[Fact]
public async Task SyncAddTest()
{
// Arrange
var input = new RpcInput
{
Method = nameof(TestRpcService.Add),
Params = JsonDocument.Parse(_paramsJson)
};
// Act
var result = await _rpcExecutor.ExecuteMethod(nameof(TestRpcService), input);
// Assert
Assert.Equal(3, result.Result);
}
Полный код тестов доступен в репозитории на гитхаб.
Вывод
В рамках данной статьи мы рассмотрели архитектуру JsonRPC, а также написали свою реализацию с использованием механизма рефлексии.
💡 Помните, что данное решение создано только для изучения архитектуры, в продуктовой среде лучше использовать существующие библиотеки или другие rpc-протоколы (например gRPC)