Почему я решил написать свой 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. Сериализация данных

Тут пришлось выбирать между:
- 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. Безопасность

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

Ошибка на сервере вызывала крах соединения. Я ввел стандарт ответа с полем `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 — не только возможно, но и оправдано в ряде случаев, особенно если у вас нестандартные требования или вы хотите полное понимание происходящего «под капотом».



