Оригинал этой статьи опубликован в журнале «Системный администратор» №12 (133) за декабрь 2013.

Технология распределенного Ruby, или dRuby (Distributed Ruby), позволяет вызывать методы объектов, находящихся в другом процессе и/или на другом компьютере. При этом установка соединения, передача необходимых данных и тому подобное — скрыты от программиста, и использование удаленных объектов мало чем отличается от работы с объектами, заданными внутри программы.
Это не единственная технология RPC, доступная при программировании на Ruby, однако более универсальные средства, такие как CORBA или XML-RPC, более сложны в использовании и требуют бо́льших накладных расходов (кроме того, поддержка CORBA не входит в стандартную библиотеку Ruby, соответственно, в сопровождении требует дополнительного внимания к совместимости версий и т.д.). В общем, если не требуется взаимодействие с программами, написанными на других языках, dRuby — очень хороший выбор, а с чем его едят и как правильно готовить, мы и рассмотрим в данной статье.
RPC — Remote Procedure Call — общее название для технологий, позволяющих программам вызывать процедуры/функции в чужом адресном пространстве, в том числе — на другом компьютере. По особенностям использования и реализации эти технологии между собой очень сильно различаются.
Из наиболее популярных можно отметить архитектуру CORBA, разрабатываемую рабочей группой OMG, и протокол DCOM, принадлежащий Microsoft (и работающий де-факто только в Windows), а также текстовые протоколы, работающие поверх HTTP — JSON-RPC и XML-RPC.
Ruby «из коробки», т.е. в рамках стандартной библиотеки, поддерживает, помимо собственной технологии dRuby, только XML-RPC. Тем не менее, можно найти и установить гемы для CORBA и JSON-RPC — r2corba и json-rpc-objects соответственно.
Технология распределенного Ruby, или dRuby (Distributed Ruby), позволяет вызывать методы объектов, находящихся в другом процессе и/или на другом компьютере. При этом установка соединения, передача необходимых данных и тому подобное — скрыты от программиста, и использование удаленных объектов мало чем отличается от работы с объектами, заданными внутри программы.
Это не единственная технология RPC, доступная при программировании на Ruby, однако более универсальные средства, такие как CORBA или XML-RPC, более сложны в использовании и требуют бо́льших накладных расходов (кроме того, поддержка CORBA не входит в стандартную библиотеку Ruby, соответственно, в сопровождении требует дополнительного внимания к совместимости версий и т.д.). В общем, если не требуется взаимодействие с программами, написанными на других языках, dRuby — очень хороший выбор, а с чем его едят и как правильно готовить, мы и рассмотрим в данной статье.
Минимальный пример
Рассмотрим код1 простейшего сервера:
require 'drb'
class Server
def alpha arg
puts "Alpha called with #{arg.inspect}"
[:alpha, arg]
end
end
DRb.start_service 'druby://localhost:9000', Server.new
DRb.thread.join
Как видим, ничего особенного делать не приходится — берем самый обыкновенный объект и запускаем сервис, выставляющий
его на определенный адрес и порт. Последняя строка заставляет программу дожидаться завершения нити-обработчика запросов;
в реальном проекте нам потребуется определить какой-то способ корректного завершения — с сохранением данных, еще какими-то
действиями… Но это имеет лишь опосредованное отношение к теме статьи, поэтому перегружать пример не будем. Поскольку
запускать сервер мы будем из консоли, для завершения достаточно использовать сочетание клавиш Ctrl-C
.
И клиента:
require 'drb'
a = DRbObject.new nil, 'druby://localhost:9000'
p a.alpha(1)
p a.alpha(nil)
p a.alpha("beta")
Здесь тоже ничего особенного — создаем специальный объект-посредник и обращаемся с ним, как с тем объектом, который мы создали на сервере.
Запустим в одной консоли сервер, в другой клиент, получим:
$ ruby 01_c.rb
[:alpha, 1]
[:alpha, nil]
[:alpha, "beta"]
Кроме того, вернувшись в консоль сервера, видим:
Alpha called with 1
Alpha called with nil
Alpha called with "beta"
Что свидетельствует о выполнении метода именно на той стороне.
Передача данных
Уже из вышеприведенного можно видеть, что обмен данными простых стандартных классов происходит полностью прозрачно —
мы использовали число, строку, nil
и массив — все они выглядят на стороне клиента и сервера совершенно одинаково.
А что произойдет, если мы будем передавать нестандартные, определенные нами же объекты? Попробуем — определим класс
на сервере:
class Alpha
def dummy
:dummy
end
end
class Server
def alpha
Alpha.new
end
end
И попытаемся обратиться к нему на клиенте:
s = DRbObject.new nil, 'druby://localhost:9000'
a = s.alpha
p a
p a.dummy
Результат нас немного огорчит:
$ ruby 02_c.rb
♯‹DRb::DRbUnknown:0x000000007ff788 @name="Alpha", @buf="\x04\bo:\nAlpha\x00"›
cln1.rb:11:in `‹main›': undefined method `dummy' for ♯‹DRb::DRbUnknown:0x000000007ff788› (NoMethodError)
Т.е. мы получили некий неизвестный объект, с которым клиентская сторона не знает, что делать. С другой стороны,
это логично — откуда же ей знать?.. Однако, никаких проблем не возникнет, если мы определим класс объекта и там и там.
Лучше всего сделать это в отдельном файле, который подключается через require
.
Но, вообще говоря, откуда Ruby знать, что некий класс на сервере и одноименный класс на клиенте — это одно и то же? А никто этого и не обещает. В действительности это могут быть совершенно разные классы, нужно лишь соблюдать совместимость по маршалингу — это встроенный механизм Ruby для сохранения/восстановления произвольных значений. В простых случаях о его работе задумываться не приходится — по умолчанию при маршализации объекта просто маршализуются и сохраняются его внутренние переменные, но могут быть и случаи сложные:
-
Объект наследует/использует немаршализуемые объекты, плотно связанные с текущим окружением —
Proc
,IO
и так далее. -
Класс объекта определен во внешней бинарной библиотеке, и Ruby ничего не знает о его состоянии.
-
По каким-то причинам классы на клиенте и сервере существенно разные…
В такой ситуации мы можем определить методы marshal_dump
/marshal_load
, которые будут приводить данные объекта
к какому-нибудь общему знаменателю (например, в виде хэша, содержащего только маршализуемые значения) и, соответственно,
восстанавливать объект по этому значению.
Другой же вариант — наоборот, объявить объект немаршализуемым, в этом случае мы сможем к нему обращаться так же, как
и к объекту-серверу — через объект-посредник класса DRbObject
. Используя библиотеку drb
, проще всего это сделать
включением миксин-модуля DRbUndumped
. Т.е. определив на сервере:
class Gamma
include DRbUndumped
def dummy
puts 'Dummy called'
:dummy
end
end
class Server
def gamma
Gamma.new
end
end
И вызвав на клиенте s.gamma.dummy
, мы увидим вывод строки «Dummy called» в серверной, а не клиентской консоли.
Клиентская часть
Несмотря на то, что работа в целом идет по асимметричной схеме клиент-сервер, обмен данными вполне симметричен,
и мы вполне можем передать серверу объект, методы которого будут выполняться на клиенте. Самый простой способ
это продемонстрировать — передать блок (который сам по себе является объектом класса Proc
). Пусть у нас будет
следующий сервер:
class Server
include DRbUndumped
def doSmth &block
puts '--- server'
block.call self
end
end
DRb.start_service 'druby://localhost:9000', Server.new
DRb.thread.join
И соответствующий клиент:
require 'drb'
DRb.start_service
s = DRbObject.new nil, 'druby://localhost:9000'
s.doSmth do |srv|
puts '--- client'
end
Запустив всё это, мы убедимся, что строка «— server» выводится в серверной консоли, тогда как «— client» — в клиентской, как и следовало ожидать.
Тут возник один нюанс — в клиентской части мы использовали, в отличие от предыдущих примеров, вызов DRb.start_service
без параметров.
Это важно, именно эта строчка обеспечивает возможность вызова клиентских методов с сервера.
Здесь возникает вопрос — а можем ли мы как-то передать блок кода серверу, чтобы он выполнил его у себя, в своем контексте?
Вообще говоря, нет — объекты, представляющие код, не маршализуются — ни Proc
, ни Method
. Однако, мы можем заставить сервер
выполнить произвольную строку, для этого нам понадобится следующий хак (на стороне клиента):
s.instance_eval 'undef :instance_eval'
s.instance_eval 'p "client str"'
Первой строчкой мы удаляем метод instance_eval
объекта-посредника, что заставляет его в дальнейшем передавать вызов серверу,
ну а дальше выполняем произвольную строку… Что плавно подводит нас к следующей теме.
Безопасность
Очевидно, что сервер, работающий по приципу: «ходи, кто хочет, бери, что хочет» — это плохой сервер, и надо бы как-то
это дело ограничить. Начнем со второй части — запретим выполнять произвольный код. Можно, например, поудалять у серверного
объекта все потенциально опасные методы, такие как instance_eval
, но в случае большой и сложной программы отследить их
все будет не так-то просто. Лучше воспользоваться уровнями безопасности Ruby и тем фактом, что метод start_service
имеет
необязательный третий параметр — хэш различных опций, в частности — :safe_level
. Чтобы запретить выполнение произвольной
строки, достаточно выставить его в единицу, и злонамеренный клиент свалится с исключением:
DRb.start_service 'druby://localhost:9000', Server.new,
:safe_level => 1
Более высокие уровни добавят еще больше ограничений, подробнее см. документацию2. Уровень безопасности можно выставить
и глобально для всей программы через переменную $SAFE
, однако задание его для конкретного сервиса более гибко и, на мой взгляд,
удобно.
Теперь вернемся к «ходи, кто хочет» и ограничим доступ. Здесь подход будет зависеть от того, с кем собственно сервер должен взаимодействовать. Варианты следующие:
-
Отдельные пользователи на локальной системе (вариант «управление службой», см. далее раздел «Сценарии использования»).
-
Клиенты в локальной сети, параметры которой нам известны.
-
Клиенты в глобальной сети, могут обращаться из заранее неизвестного места.
В первом случае нам, возможно, имеет смысл использовать не TCP/IP, как во всех примерах выше, а протокол сокетов UNIX (правда, под Windows этот способ не пройдет). Управление доступом в этом случае производится посредством обычных прав на доступ к файлам. Запуск сервиса будет выглядеть примерно так:
require 'drb/unix'
DRb.start_service 'drbunix:/tmp/mydrb', Server.new,
:UNIXFileMode => 0660
Соответственно, смогут подключиться только те клиенты, которые запущены от имени пользователя, входящего в группу сервера.
Кроме :UNIXFileMode
доступны также параметры :UNIXFileOwner
и :UNIXFileGroup
.
В локальной сети (и вообще, если IP-адреса клиентов заранее известны и фиксированы), мы можем воспользоваться расширением ACL, примерно так:
require 'drb/acl'
acl = ACL.new(%w(
deny all
allow localhost
allow ::1
allow 192.168.1.*
))
DRb.start_service 'druby://localhost:9000', Server.new,
:tcp_acl => acl
В более же общем случае нам придется прибегнуть к шифрованным безопасным соединениям. Здесь есть два пути: ssh-туннель и соединение через SSL.
Для ssh-туннелирования нам потребуется пробросить пару портов — в прямом и обратном направлениях, а так же установить явно адрес клиентского сервиса. Серверный код не изменится.
На клиенте в отдельной консоли выполняем:
$ ssh -N -L 9000:127.0.0.1:9000 \
-R 9001:127.0.0.1:9001 server
Где «server» — имя или адрес сервера, а в коде клиента пишем:
DRb.start_service 'druby://127.0.0.1:9001'
s = DRbObject.new nil, 'druby://127.0.0.1:9000'
Команда ssh
(и, на серверной стороне, служба sshd
) берется из пакета OpenSSH3, который стандартен для unix-систем,
но вполне доступен и для Windows. Управление доступом в этом варианте обеспечивается совместно с доступом по протоколу SSH вообще,
что можно считать как преимуществом — используется стандартный инструмент администратора, так и недостатком — отдельный доступ
только для dRuby настроить не получится.
Наиболее гибкая, но и трудоемкая, настройка безопасности возможна при работе через SSL (Secure Socket Layer). Вкратце: соединение
производится по адресу вида «drbssl://server:port
», кроме того, в опциях start_service
, как на сервере, так и на клиенте нужно
передать ключи шифрования и сертификаты, которыми эти ключи подписаны… А еще сами сертификаты должны быть подписаны удостоверяющим
центром и т.д. — по большому счету, эта тема заслуживает отдельного рассмотрения4.
Сценарии использования
Итак, с чем же его едят? Что полезного мы можем сделать, используя технологию dRuby?
Во-первых, мы можем создать фоновую службу и, отдельно, управляющую утилиту, убрав таким образом из постоянно загруженной программы все интерфейсные элементы, при этом обладая максимальной свободой в управлении ею «на ходу».
Во-вторых, через dRuby легко реализуется классическая трехзвенная архитектура, где вся бизнес-логика работает в отдельном процессе, а приложения, обеспечивающие интерфейс, хоть настольные, хоть формирующие веб-сайт, существуют и разрабатываются независимо. Причем для разных категорий клиентов мы можем выдавать разные объекты-серверы. Собственно даже само разделение бизнес-логики и отображения на разные процессы, разные репозитории исходного кода — это уже весьма полезно в плане надежности и легкости сопровождения.
Ну и, наконец, возможность разрабатывать полностью гетерогенные и многоуровневые системы, элементы которых разнесены не только в пространстве, но и организационно, и обмениваются не всеми имеющимися данными, а только нужными. Условно говоря, если у нас сеть магазинов, то необязательно отправлять в базу данных центрального офиса информацию по каждому пробитому чеку, достаточно общей сводки. А еще можно к этому добавить подразделения, нуждающиеся в какой-то специфической информации (например, сервисный центр), приложения для взаимодействия с постоянными партнерами и так далее — гибкость лишней не будет. Причем все программы у нас независимы и, при правильном проектировании, сбой в одной из них не затронет все остальные.
Кроме того, таким образом можно связывать уже имеющиеся информационные системы, если, конечно, они работают с Ruby. Особо интересен тут факт, что версии Ruby могут быть разными — требуется только совпадение версий формата маршалинга, который на данный момент во всех активно используемых версиях — от 1.8.7 до 2.1 — один и тот же. Конечно, можно обойтись и другими средствами — веб-сервисами, например, обменивающимися данными в формате JSON или XML, но это дополнительные расходы, как машинной нагрузки, так и времени разработки, на которые, вероятно, стоит идти только если у нас уже имеются какие-то элементы системы, работающие по данным протоколам. Хотя и в этом случае можно рассмотреть вариант ruby-обертки или расширения, особенно если в долгосрочных планах всё равно стоит их переработка или замена — тут уже по обстоятельствам.
-
Полные исходные тексты примеров размещены по адресу https://gist.github.com/shikhalev/6945234. ↩
-
Dave Thomas, with Chad Fowler and Andy Hunt, «Programming Ruby: The Pragmatic Programmers’ Guide», бесплатная версия первого издания — http://www.ruby-doc.org/docs/ProgrammingRuby/ [en]. Глава «Locking Ruby in the Safe». ↩
-
Официальный сайт OpenSSH — http://www.openssh.org/. ↩
-
Много полезной информации можно почерпнуть из документации к OpenSSL — http://www.openssl.org/, неплохое введение на русском языке находится по адресу http://xgu.ru/wiki/OpenSSL. ↩