Свой JsonRPC на ASP.NET Core с нуля 

Свой 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:

Swagger

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

Для закрепления материала и проверки всех вариантов вызова метода попробуйте добавить больше методов и/или сервисов и вызвать их таким способом.

Юнит-тесты

Теперь напишем юнит-тесты для наших кейсов. Юнит-тест представляет собой метод, которая проверяет определённый функционал определённого модуля. В нашем случае проверять будем RpcExecutor.

Каждый юнит-тест состоит из трёх этапов:

  1. Arrange - подготовка данных
  2. Act - выполнение действия
  3. 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)

Спасибо за внимание, с вами был FlexCode. Исходный код тут.

Report Page