пятница, 3 апреля 2015 г.

[prog] Совсем чуть-чуть Ruby-новой магии

Под катом маленький пример того, за что мне очень нравится язык Ruby.

Потребовалось создать пару десятков файлов, каждый из которых должен был бы выглядеть приблизительно так:

require 'mxx_ru/binary_unittest'

MxxRu::setup_target(
  MxxRu::BinaryUnittestTarget.new(
    "test/so_5/samples_as_unit_tests/coop_listener.ut.rb",
    "sample/so_5/coop_listener/prj.rb" )
)

Причем был очень важный фактор: имя если внутри файла использовалось, скажем, имя coop_listener, то и сам файл должен был называться coop_listener.ut.rb. Если файл называется exception_reaction.ut.rb, то это имя должно быть дважды указано у него внутри:

MxxRu::setup_target(
  MxxRu::BinaryUnittestTarget.new(
    "test/so_5/samples_as_unit_tests/exception_reaction.ut.rb",
    "sample/so_5/exception_reaction/prj.rb" )
)

Понятное дело, что повторять имя файла дважды внутри самого файла не интересно, поэтому появился приблизительно такой вариант:

require 'mxx_ru/binary_unittest'

sample_name = 'exception_reaction'

MxxRu::setup_target(
  MxxRu::BinaryUnittestTarget.new(
    "test/so_5/samples_as_unit_tests/#{sample_name}.ut.rb",
    "sample/so_5/#{sample_name}/prj.rb" )
)

Уже лучше, но все равно имя дублировалось -- сначала нужно было должным образом назвать .ut.rb-файл, а затем повторить это же имя внутри .ut.rb-файла. Поэтому был сделан следующий шаг, использующий возможность получить имя исходного файла:

require 'mxx_ru/binary_unittest'

sample_name = File.basename(__FILE__'.ut.rb')

MxxRu::setup_target(
  MxxRu::BinaryUnittestTarget.new(
    "test/so_5/samples_as_unit_tests/#{sample_name}.ut.rb",
    "sample/so_5/#{sample_name}/prj.rb" )
)

Т.е. из полного имени текущего исходного файла (__FILE__) удаляется путь и остается только имя файла с удаленным расширением '.ut.rb' (это осуществляется методом File.basename). Полученый результат сохраняется в переменной sample_name, и затем через string-interpolation подставляется в соответствующие места.

Казалось бы, цель достигнута: можно копировать один и тот же файл под разными именами и не нужно ничего исправлять внутри него.

Но смущал объем копипасты, который был внутри каждого файла. А то, если со временем нужно было бы заменить строку 'test/so_5/samples_as_unit_tests' на какую-нибудь другую? Править все 20 файлов? Неприятная перспектива.

Поэтому была сделана следующая итерация:

require_relative 'details.rb'

setup_sample_as_unit_test File.basename(__FILE__,'.ut.rb')

Всего две строки. Уже совсем хорошо. Почти.

Все-таки мое чувство прекрасного смущало повторение операций с File.basename. Ведь, если подумать, можно и от них избавиться, если использовать тот факт, что в Ruby можно получить стек вызова, в котором будут указаны имена исходных файлов. Что позволило записать .ut.rb-файлы вот так:

require_relative 'details.rb'

setup_sample_as_unit_test

Вся магия кроется внутри файлика details.rb. Который чуть помудренее, чем показанный выше код:

require 'mxx_ru/binary_unittest'

def setup_sample_as_unit_test(sample_name = nil)
  ut_name = File.basename( /^(.+):\d/.match( caller(1,1)[0] )[1] )
  sample_name = File.basename( ut_name, '.ut.rb' ) unless sample_name

  MxxRu::setup_target(
    MxxRu::BinaryUnittestTarget.new(
      "test/so_5/samples_as_unit_tests/#{ut_name}",
      "sample/so_5/#{sample_name}/prj.rb" )
  )
end

Вся магия сосредоточена в первой строке функции setup_sample_as_unit_test :)

Сначала берется информация о том, кто именно и откуда вызвал setup_sample_as_unit_test (конструкция caller(1,1)[0] -- caller возвращает вектор и нам нужен его первый элемент).

Далее из полученной строки вида "test/so_5/samples_as_unit_tests/exception_reaction.ut.rb:3:in `main'" нужно вытащить только имя файла, без номера строки и дополнительной информации. Для этого используется регулярное выражение /^(.+):\d/. Оно вычленяет все, что находится до двоеточия, после которого есть хотя бы одна цифра. Вычленение происходит посредством метода Regex#match, который возвращает массив совпадений с образцом. Нам нужен второй элемент этого массива, т.к. именно в нем окажется имя файла. Тут, кстати, есть фокус с платформой Windows: имя файла может быть абсолютным с именем диска, т.е. что-то вроде "D:/home/eao197/sandboxes/bla-bla-bla". Поэтому искать просто до двоеточия нельзя, нужно именно до двоеточия, за которым идут цифры (т.е. номер строки).

Ну а дальше все просто: из полного имени файла отбрасывается путь. Получается имя файла, из которого вызвана setup_sample_as_unit_test. Потом из этого имени удаляется расширение и получается имя примера, которое сохраняется в sample_name.

Но с sample_name оказалась небольшая закавыка. Выяснилось, что есть пара примеров, которые лежат не в sample/so_5, а внутри дополнительного подкаталога -- sample/so_5/svc. Например, код exception_reaction лежит в sample/so_5/exception_reaction, а код svc_hello в sample/so_5/svc/hello. Как учесть этот факт?

Никак :) Такие случаи нужно указывать явно. Например, файл svc_hello.ut.rb имеет вид:

require_relative 'details.rb'

setup_sample_as_unit_test 'svc/hello'

Т.е. могут быть случаи, когда setup_as_unit_test вызывается с полностью определенным значением sample_name. И это значение не нужно вычислять внутри setup_as_unit_test. Что и записывается посредством унаследованного из Perl-а оператора unless. Оператор неоднозначный, но для записи однострочников очень удобный.

В показанном решении есть один стремный момент: вычленение имени файла, из которого вызывали setup_as_unit_test, базируется на знании технических деталей реализации (т.е. того, как имя файла представлено в результатах caller-а). Но код был проверен под Ruby 2.0, 2.1 и 2.2. Так что, надеюсь, в течении какого-то времени эти детали не поменяются.

Удобство Ruby проявилось еще и в том, что все распространяется в исходниках. Поэтому когда под Linux-ом и Ruby-2.2.1 выяснилось, что скрипт работает не так, как задумывалось (сказалась разница в поведении String#[] в разных версиях Ruby), то исправить ошибку не составило труда. Просто вносишь правки и смотришь, что получается. Тогда как в языках с предварительной компиляцией нужно делать дополнительные манипуляции. А уж если утилита распространяется в кросс-платформенном бинарном виде (как Java в jar-файлах), то может вообще оказаться, что исходники лежат где-то в другом месте и под рукой их нет вовсе.

Полагаю, что такой фокус возможен и в Perl-е, и в Python-е. Вряд ли Ruby здесь имеет какие-то уникальные преимущества. Другое дело, что из этой "большой тройки" мейнстримовых скриптовых языков лично мне Ruby наиболее симпатичен. Впрочем, это уже совсем другая история... :)

Комментариев нет: