Как я написал свой rpc-фреймворк с нуля и что из этого получилось

Почему я решил написать свой RPC-фреймворк

Все началось с простого желания — ускорить взаимодействие микросервисов в одном из проектов. Мы использовали REST, но столкнулись с проблемами: JSON-обмен слишком объемный, сериализация/десериализация тормозила, а стандартный HTTP-запрос добавлял ощутимую задержку.

Исследовав альтернативы, я обратил внимание на gRPC, Thrift, Cap’n Proto и другие готовые решения. Однако у каждого из них были свои компромиссы: от громоздкой конфигурации до жесткой привязки к protobuf или IDL. Именно тогда я задался вопросом: а что, если попробовать написать свой собственный RPC-фреймворк, минималистичный, но быстрый и понятный?

Выбор архитектуры: синхронность, транспорт, сериализация

1. Синхронный vs асинхронный вызов

Первым делом я определился, как будет происходить вызов удаленной функции. Стандартный подход — синхронный RPC, когда клиент "зависает" до получения ответа. Однако такой подход плохо масштабируется. Поэтому я сразу ориентировался на поддержку асинхронного взаимодействия, чтобы обрабатывать тысячи одновременных запросов.

В итоге серверная часть была построена на базе asyncio (Python), что позволило выдерживать до 10 000 RPS на локальных тестах.

2. Выбор транспорта

Я протестировал три варианта:

1. HTTP/1.1 — простой, но медленный (лишние заголовки, текстовая обертка).
2. WebSocket — хороший компромисс, особенно для двунаправленного обмена.
3. TCP-сокеты — максимальная производительность, минимум накладных расходов.

В итоге я остановился на TCP: это дало минимальную задержку (в среднем 2–3 мс на вызов функции) и предсказуемость.

3. Сериализация данных

Как я написал свой собственный RPC-фреймворк - иллюстрация

Тут пришлось выбирать между:

- JSON: просто, но медленно
- MessagePack: бинарный и компактный
- Protobuf: быстрый, но требует IDL

Я выбрал MessagePack — он не требует генерации кода и сохраняет читаемость. Производительность сериализации выросла в 3 раза по сравнению с JSON.

Пример сериализации запроса и ответа

import msgpack request = { "method": "sum", "params": [5, 8], "id": 1 } packed = msgpack.packb(request)

Реализация: шаг за шагом

1. Создание RPC-сервера

Первым этапом была реализация сервера, который может принимать TCP-соединения, декодировать входящие запросы и вызывать соответствующие функции.

class RPCServer:
    def __init__(self, host='0.0.0.0', port=5000):
        self.methods = {}
        self.host = host
        self.port = port

    def register(self, name, func):
        self.methods[name] = func

    async def handle_client(self, reader, writer):
        raw = await reader.read(1024)
        request = msgpack.unpackb(raw)
        method = request['method']
        params = request['params']
        response = {
            'id': request['id'],
            'result': self.methods[method](*params)
        }
        writer.write(msgpack.packb(response))
        await writer.drain()
        writer.close()

2. Клиентская часть

Клиент реализует простую обертку для вызова методов по имени:

class RPCClient:
    def __init__(self, host='127.0.0.1', port=5000):
        self.host = host
        self.port = port
        self.id = 0

    async def call(self, method, *params):
        reader, writer = await asyncio.open_connection(self.host, self.port)
        self.id += 1
        request = {
            'method': method,
            'params': params,
            'id': self.id
        }
        writer.write(msgpack.packb(request))
        await writer.drain()
        raw = await reader.read(1024)
        response = msgpack.unpackb(raw)
        return response['result']

3. Регистрация и вызов функций

server = RPCServer()
server.register("sum", lambda x, y: x + y)
asyncio.run(server.serve())

На клиенте:

client = RPCClient()
result = asyncio.run(client.call("sum", 3, 4))
print(result)  # 7

Проблемы и решения

1. Безопасность

Как я написал свой собственный RPC-фреймворк - иллюстрация

Поначалу я не ограничивал перечень вызываемых методов. Это быстро стало проблемой. Для защиты я внедрил whitelisting функций и ограничил доступ по IP.

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

Как я написал свой собственный RPC-фреймворк - иллюстрация

Ошибка на сервере вызывала крах соединения. Я ввел стандарт ответа с полем `error`, чтобы клиент мог обработать исключения.

try:
    result = self.methods[method](*params)
    response = {'id': request['id'], 'result': result}
except Exception as e:
    response = {'id': request['id'], 'error': str(e)}

3. Версионирование

Чтобы избежать несовместимости, я ввел версию протокола в заголовке запроса. Это позволило клиенту и серверу договариваться о формате данных.

Что я узнал из этого опыта

Создание RPC-сервера с нуля — непростая, но крайне полезная задача. Написание собственного RPC-фреймворка позволяет глубже понять, как работают распределенные системы, сериализация, и где теряется производительность.

Некоторые ключевые факты:

- TCP + MessagePack = до 75% производительности gRPC при меньших накладных расходах.
- Асинхронная архитектура — обязательна при высокой нагрузке.
- Простота интерфейса — залог устойчивости и масштабируемости.

Сравнение с готовыми решениями

Вот как мой фреймворк сравнивается с популярными альтернативами:

1. gRPC — быстрее, но требует protobuf и сложной инфраструктуры.
2. Thrift — гибкий, но сложный в настройке.
3. JSON-RPC — простой, но медленный и не бинарный.
4. Мой фреймворк — легкий, с открытым протоколом и минимальной зависимостью.

Заключение

Если вы задумываетесь, как создать RPC-фреймворк с нуля — начните с малого: выберите простой транспорт, удобную сериализацию и реализуйте базовую маршрутизацию вызовов. Разработка RPC-фреймворка — это не только про производительность, но и про контроль, прозрачность и адаптацию под нужды команды.

Мой опыт показывает: написание собственного RPC — не только возможно, но и оправдано в ряде случаев, особенно если у вас нестандартные требования или вы хотите полное понимание происходящего «под капотом».

Прокрутить вверх