shikhalev.org

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


Технология распределенного Ruby, или dRuby (Dis­­t­­ri­­bu­­ted 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 (Dis­­t­­ri­­bu­­ted Ruby), позволяет вызывать методы объектов, находящихся в другом процессе и/или на другом компьютере. При этом установка соединения, передача необходимых данных и тому подобное — скрыты от программиста, и использование удаленных объектов мало чем отличается от работы с объектами, заданными внутри программы.

Это не единственная технология RPC, доступная при про­­г­­рам­­ми­­ро­­ва­­нии на Ru­by, однако более универсальные средства, такие как 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-обертки или расширения, особенно если в долгосрочных планах всё равно стоит их переработка или замена — тут уже по обстоятельствам.

  1. Полные исходные тексты примеров размещены по адресу https://gist.github.com/shikhalev/6945234

  2. 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». 

  3. Официальный сайт OpenSSH — http://www.openssh.org/

  4. Много полезной информации можно почерпнуть из документации к OpenSSL — http://www.openssl.org/, неплохое введение на русском языке находится по адресу http://xgu.ru/wiki/OpenSSL