Статьи

Изучите метапрограммирование на Ruby

Rr

Ruby Metaprogramming может быть хорошей вещью.

Недавно я просматривал один из кодов моего студента. У него была программа со многими методами, которая распечатывала различные сообщения, например:

class MyClass def calculate(a) result = a ** 2 puts "The result is #{result}" end end class MyOtherClass def some_action(a, b) puts "The first value is #{a}, the second is #{b}" end def greet puts "Welcome!" end end 

Я предложил ему хранить эти сообщения в отдельном файле, чтобы упростить процесс работы с ними. Затем я вспомнил, как переводы I18n хранятся в Rails, и получил идею. Почему бы нам не создать файл YAML (так называемый словарь ) со всеми сообщениями и вспомогательным методом для их правильной выборки, при этом поддерживая дополнительные функции, такие как интерполяция? Это вполне возможно с метапрограммированием Руби!

Вот так и появился гем messages_dictionary (созданный в основном для изучения) — я даже использовал его в нескольких других моих проектах. Итак, в этой статье мы увидим метапрограммирование Руби в действии при написании этого драгоценного камня с нуля.

Основная структура драгоценного камня

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

  • Gemfile
  • Gemspec, который содержит системную информацию. Вы можете проверить исходный код драгоценного камня на GitHub, чтобы увидеть, как они выглядят.
  • Rakefile содержит инструкции для загрузки тестов, написанные на RSpec.
  • .rspec содержит параметры для RSpec. В этом случае, например, я хочу, чтобы тесты выполнялись в случайном порядке, файл spec_helper.rb должен быть обязательным по умолчанию, а вывод должен быть разноцветным. Разумеется, эти параметры можно установить и при запуске RSpec с терминала.
  • .travis.yml содержит конфигурацию для службы Travis CI, которая автоматически запускает тесты для каждого запроса на фиксацию или получение. Это действительно отличный сервис, так что попробуйте, если вы никогда не видели его раньше.
  • README.md содержит документацию к самоцвету.
  • spec / содержит все тесты, написанные на RSpec. Я не буду освещать тесты в этой статье, но вы можете изучить их самостоятельно.
  • lib / содержит основной код драгоценного камня.

Давайте начнем работу в каталоге lib . Прежде всего, создайте файл messages_dictionary.rb и папку messages_dictionary . messages_dictionary.rb потребует все сторонние гемы, а также некоторые другие файлы и определит наш модуль. Иногда конфигурация также находится внутри этого файла, но мы не будем этого делать.

Библиотека / messages_dictionary.rb

 require 'yaml' require 'hashie' module MessagesDictionary end 

Довольно минималистично. Обратите внимание, что этот драгоценный камень имеет две зависимости: YAML и Hashie . YAML будет использоваться для анализа файлов .yml, тогда как Hashie предоставляет набор действительно классных расширений для базовых классов Array и Hash. Откройте эту страницу RubyGems и обратите внимание, что Hashie находится в разделе Зависимости. Это потому, что внутри gemspec у нас есть следующая строка:

 spec.add_dependency 'hashie', '~> 3.4' 

Парсер YAML является частью ядра Ruby, но Hashie — это нестандартное решение, поэтому мы должны указать его как зависимость.

Теперь внутри lib / messages_dictionary создайте файл version.rb :

Библиотека / messages_dictionary / version.rb

 module MessagesDictionary VERSION = 'GEM_VERSION_HERE' end 

Обычной практикой является определение версии драгоценного камня как константы. Далее, внутри файла gemspec ссылка на эту константу:

 spec.version = MessagesDictionary::VERSION 

Также обратите внимание, что весь код драгоценного камня находится под пространством имен в модуле MessagesDictionary . Пространство имен очень важно, потому что в противном случае вы можете ввести конфликты имен. Предположим, кто-то хочет использовать этот драгоценный камень в своем собственном проекте, но где-то уже определена константа VERSION (в конце концов, это очень распространенное имя). Размещение версии драгоценного камня вне модуля может переписать эту константу и привести к ошибкам, которые трудно обнаружить. Поэтому придумайте имя для вашего драгоценного камня и убедитесь, что это имя еще не используется Googling, а затем поместите пространство имен всего кода под этим именем.

Итак, приготовления сделаны, и мы можем начать кодирование!

Динамическое определение метода

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

 require 'messages_dictionary' 

Далее наш модуль должен быть включен:

 class MyClass include MessagesDictionary end 

Тогда должен быть метод, чтобы сказать MessagesDictionary делать свою работу. Давайте назовем этот метод has_messages_dictionary , вдохновленный Rails ‘ имеет безопасный пароль :

 class MyClass include MessagesDictionary has_messages_dictionary end 

Следующим шагом для пользователя является создание файла .yml, содержащего сообщения:

 hi: "Hello there!" 

Наконец, для отображения этого сообщения необходимо вызвать специальный метод:

 class MyClass include MessagesDictionary has_messages_dictionary def greet pretty_output(:hi) # Prints "Hello there!" in the terminal end end 

Это несколько базовый функционал, но мы расширим его позже.

Создайте новый файл с именем injector.rb в каталоге lib / messages_dictionary . Большой вопрос, как оснастить класс дополнительным методом has_messages_dictionary на лету? К счастью для нас, Ruby представляет специальный метод ловушки, называемый included который запускается после включения модуля в класс.

Библиотека / messages_dictionary / injector.rb

 module MessagesDictionary def self.included(klass) end end 

included метод класса, поэтому мне нужно добавить к нему префикс self . Этот метод принимает объект, представляющий класс, который включил этот модуль. Обратите внимание, что я специально назвал эту локальную переменную klass , потому что class является зарезервированным словом в Ruby.

Что мы хотим делать дальше? Очевидно, определите новый метод с именем has_messages_dictionary . Однако мы не можем использовать def для этого — это должно быть сделано динамически во время выполнения. Также обратите внимание, что has_messages_dictionary должен быть методом класса, поэтому мы должны использовать метод define singleton . Если вы хотите узнать больше о методах синглтона, посмотрите мой скринкаст о них. Проще говоря, методы класса являются одноэлементными.

Однако есть небольшая ошибка. Если я использую define_singleton_method как это

 module MessagesDictionary def self.included(klass) define_singleton_method :has_messages_dictionary do |opts = {}| end end end 

Тогда этот метод будет определен внутри модуля MessagesDictionary но не внутри класса! Поэтому мы должны использовать еще один метод с именем class_exec, который, как вы, наверное, догадались, оценивает некоторый код в контексте некоторого класса:

Библиотека / messages_dictionary / injector.rb

 module MessagesDictionary def self.included(klass) klass.class_exec do define_singleton_method :has_messages_dictionary do |opts = {}| end end end end 

Обратите внимание, что define_singleton_method имеет локальную переменную с именем define_singleton_method по умолчанию установленную в пустой хеш. Если бы нам не пришлось определять этот метод динамически, соответствующий код выглядел бы более знакомым:

 def self.has_messages_dictionary(opts = {}) end 

Эта концепция использования included хука и определения некоторого метода в контексте другого класса довольно распространена и, например, используется в Devise .

Открытие файла

Далее наш код должен открыть файл с сообщениями. Почему мы не ожидаем, что этот файл будет назван в честь имени класса? Например, если класс называется MyClass то файл должен быть my_class.yml . Единственное, что нам нужно сделать, это преобразовать имя класса из верблюжьего в случай змеи. В то время как в Rails такой метод есть, Ruby его не предоставляет, поэтому давайте просто определим для этого отдельный класс. Код для метода case snake_case был взят из модуля Rails ActiveSupport:

Библиотека / messages_dictionary / Utils / snake_case.rb

 module MessagesDictionary class SpecialString attr_accessor :string def initialize(string) @string = string end def snake_case string.gsub(/::/, '/'). gsub(/([AZ]+)([AZ][az])/,'\1_\2'). gsub(/([az\d])([AZ])/,'\1_\2'). tr("-", "_"). downcase end end end 

Конечно, мы могли бы повторно открыть существующий класс String , но в приложении пользователя уже может быть определен такой метод, и мы не хотим его переопределять.

Используйте этот новый вспомогательный класс:

injector.rb

 # ... define_singleton_method :has_messages_dictionary do |opts = {}| file = "#{SpecialString.new(klass.name).snake_case}.yml" end 

Следующим шагом является загрузка файла и остановка выполнения программы, если он не был найден:

injector.rb

 # ... define_singleton_method :has_messages_dictionary do |opts = {}| file = "#{SpecialString.new(klass.name).snake_case}.yml" begin messages = YAML.load_file(file) rescue Errno::ENOENT abort "File #{file} does not exist..." # you may raise some custom error instead end end 

messages будут содержать хэш, основанный на содержимом файла, но я бы хотел предложить пользователям некоторую гибкость, позволяя им получить доступ к сообщению, указав в качестве ключа символ или строку. Это называется безразличным доступом, и Хэши его поддерживает.

Чтобы добавить эту функцию, определите специальный класс с именем Dict и добавьте туда соответствующие модули:

Библиотека / messages_dictionary / Utils / dict.rb

 module MessagesDictionary class Dict < Hash include Hashie::Extensions::MergeInitializer include Hashie::Extensions::IndifferentAccess end end 

Теперь немного подправим основной файл:

injector.rb

 # ... messages = Dict.new(YAML.load_file(file)) # ... 

Последний шаг в этом разделе — где-то хранить эти сообщения. Давайте использовать константу класса для этого. Вот результирующий код:

injector.rb

 module MessagesDictionary def self.included(klass) klass.class_exec do define_singleton_method :has_messages_dictionary do |opts = {}| file = "#{SpecialString.new(klass.name).snake_case}.yml" begin messages = Dict.new(YAML.load_file(file)) rescue Errno::ENOENT abort "File #{file} does not exist..." end klass.const_set(:DICTIONARY_CONF, {msgs: messages}) end end end end 

const_set динамически создает константу с именем DICTIONARY_CONF для класса. Эта константа содержит хеш с нашими сообщениями. Позже мы будем хранить дополнительные параметры в этой константе.

Поддержка опций

Наш скрипт начинает набирать форму, но он слишком жесткий. Вот некоторые очевидные улучшения, которые необходимо ввести:

  • Должна быть возможность предоставить файл пользовательских сообщений.
  • В настоящее время файл сообщений должен быть помещен в тот же каталог, что и скрипт, что не очень удобно. Следовательно, должна быть возможность определить собственный путь к файлу.
  • В некоторых случаях может быть удобнее передать хеш с сообщениями непосредственно в скрипт, а не создавать файл.
  • Должна быть возможность хранить вложенные сообщения, как в файлах локали Rails.
  • По умолчанию все сообщения будут распечатаны в STDOUT с использованием метода puts , но пользователи могут захотеть изменить это.

Некоторые из этих функций могут показаться сложными, но в действительности их несколько просто реализовать. Давайте начнем с пользовательского пути и имен файлов.

Предоставление пользовательского пути

Мы собираемся проинструктировать пользователей предоставить опции :dir и :file , если они хотят переопределить имя пути по умолчанию. Вот новая версия скрипта:

injector.rb

 # ... define_singleton_method :has_messages_dictionary do |opts = {}| file = "#{SpecialString.new(klass.name).snake_case}.yml" begin file = opts[:file] || "#{SpecialString.new(klass.name).snake_case}.yml" file = File.expand_path(file, opts[:dir]) if opts[:dir] rescue Errno::ENOENT abort "File #{file} does not exist..." end klass.const_set(:DICTIONARY_CONF, {msgs: messages}) end 

В основном мы изменили только две строки кода. Мы либо выбираем предоставленное пользователем имя файла, либо генерируем его на основе имени класса, а затем используем метод expand_path, если они предоставляют каталог.

Теперь пользователь может предоставить такие параметры:

 has_messages_dictionary file: 'test.yml', dir: 'my_dir/nested_dir' 

Передача хеша сообщений

Это простая функция для реализации. Просто предоставьте поддержку опции messages :

injector.rb

 # ... define_singleton_method :has_messages_dictionary do |opts = {}| if opts[:messages] messages = Dict.new(opts[:messages]) else file = opts[:file] || "#{SpecialString.new(klass.name).snake_case}.yml" file = File.expand_path(file, opts[:dir]) if opts[:dir] begin # ... end end end 

Теперь сообщения могут быть представлены в виде хэша:

 has_messages_dictionary messages: {hi: 'hello!'} 

Поддержка вложенности и отображения сообщений

Эта функция немного сложнее, но вполне осуществима с помощью метода deep_fetch Хэши . Он принимает один или несколько аргументов, представляющих ключи хеша (или индексы массива), и делает все возможное, чтобы найти соответствующее значение. Например, если у нас есть этот хеш:

 user = { name: { first: 'Bob', last: 'Smith' } } 

Ты можешь сказать:

 user.deep_fetch :name, :first 

чтобы получить «Боб». Этот метод также принимает блок, который будет выполняться, когда запрошенное значение не может быть найдено:

 user.deep_fetch(:name, :middle) { |key| 'default' } 

Однако перед использованием этого метода нам нужно расширить наш объект новыми функциональными возможностями:

injector.rb

 # ... klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch)}) # ... 

Давайте определимся, как пользователи должны указывать путь к вложенному значению. Почему бы нам не использовать подход Rails I18n, где ключи разделены . :

 pretty_output('some_key.nested_key') 

pretty_output время добавить фактический метод pretty_output :

injector.rb

 # ... define_method :pretty_output do |key| msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do raise KeyError, "#{key} cannot be found in the provided file..." end end 

Возьмите ключ и разделите его . в результате чего массив. Затем элементы массива должны быть преобразованы в аргументы метода, поэтому мы добавляем к этой команде префикс * . Затем получите запрашиваемое значение или выдайте ошибку, если ничего не найдено.

Вывод сообщения

Наконец, мы отобразим сообщение, позволяя переопределить выходное местоположение и метод, который будет использоваться. Давайте назовем эти две новые опции output (по умолчанию STDOUT ) и method (по умолчанию :puts ):

injector.rb

 # ... klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch), output: opts[:output] || STDOUT, method: opts[:method] || :puts}) # ... 

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

injector.rb

 # ... define_method :pretty_output do |key| msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do raise KeyError, "#{key} cannot be found in the provided file..." end klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg) end 

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

интерполирование

Интерполяция значений в строку является очень распространенной практикой, поэтому, конечно, наша программа должна поддерживать это. Нам нужно только выбрать некоторые специальные символы, чтобы отметить заполнитель интерполяции. Я пойду в стиле Handlebars {{ and }} но, конечно, вы можете выбрать что-нибудь еще:

 show_result: "The result is {{result}}. Another value is {{value}}" 

Значения будут переданы в виде хэша:

 pretty_output(:show_result, result: 2, value: 50) 

Это означает, что метод pretty_output должен принять еще один аргумент:

injector.rb

 # ... define_method :pretty_output do |key, values = {}| end 

Чтобы заменить заполнитель фактическим значением, мы будем использовать gsub! :

injector.rb

 # ... define_method :pretty_output do |key, values = {}| msg = klass::DICTIONARY_CONF[:msgs].deep_fetch(*key.to_s.split('.')) do raise KeyError, "#{key} cannot be found in the provided file..." end values.each do |k, v| msg.gsub!(Regexp.new('\{\{' + k.to_s + '\}\}'), v.to_s) end klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg) end 

Пользовательские преобразования и финализация драгоценного камня

Последняя функция, которую необходимо реализовать, — это возможность предоставления пользовательских преобразований для сообщений. Под этим я подразумеваю пользовательские операции, которые должны применяться к извлеченной строке. Например, иногда вам может понадобиться просто извлечь строку, нигде не отображая ее. В других случаях вы можете использовать заглавные буквы или удалить некоторые символы и т. Д.

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

injector.rb

 # ... define_method :pretty_output do |key, values = {}, &block| end 

Просто запустите код, если блок предоставлен, в противном случае выполните операцию по умолчанию:

injector.rb

 # ... define_method :pretty_output do |key, values = {}, &block| # ... block ? block.call(msg) : klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg) end 

Удаляя символ & из имени блока, мы превращаем его в процедуру. Далее просто используйте метод call, чтобы запустить эту процедуру и передать ей наше сообщение.

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

 pretty_output(:welcome) do |msg| msg.upcase! msg # => Returns "WELCOME", does not print anything end 

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

injector.rb

 # ... define_singleton_method :has_messages_dictionary do |opts = {}| # ... klass.const_set(:DICTIONARY_CONF, {msgs: messages.extend(Hashie::Extensions::DeepFetch), output: opts[:output] || STDOUT, method: opts[:method] || :puts, transform: opts[:transform]}) end define_method :pretty_output do |key, values = {}, &block| # ... transform = klass::DICTIONARY_CONF[:transform] || block transform ? transform.call(msg) : klass::DICTIONARY_CONF[:output].send(klass::DICTIONARY_CONF[:method].to_sym, msg) end 

Вы можете сказать, block || klass::DICTIONARY_CONF[:transform] block || klass::DICTIONARY_CONF[:transform] вместо этого, чтобы сделать блок переданным для отдельного метода более приоритетным.

Мы закончили с функциями драгоценного камня, поэтому давайте доработаем его сейчас. pretty_output — это метод экземпляра, но мы, вероятно, не хотим, чтобы он вызывался извне класса. Поэтому давайте сделаем это приватным:

injector.rb

 # ... define_method :pretty_output do |key, values = {}, &block| # ... end private :pretty_output 

Имя pretty_output хорошее, но слишком длинное, поэтому давайте предоставим для него псевдоним:

injector.rb

 # ... private :pretty_output alias_method :pou, :pretty_output 

Теперь отображать сообщение так же просто, как сказать

 pou(:welcome) 

Самый последний шаг — запросить все файлы в правильном порядке:

Библиотека / messages_dictionary.rb

 require 'yaml' require 'hashie' require_relative 'messages_dictionary/utils/snake_case' require_relative 'messages_dictionary/utils/dict' require_relative 'messages_dictionary/injector' module MessagesDictionary end 

Вывод

В этой статье мы увидели, как метапрограммирование Ruby можно использовать в реальном мире, и написали гем MessagesDictionary который позволяет нам легко извлекать и работать со строками. Надеюсь, теперь вы чувствуете себя немного увереннее в использовании таких методов, как included , define_singleton_method , send , а также в работе с блоками.

Окончательный результат доступен на GitHub вместе с подробной документацией. Вы также можете найти тесты RSpec там. Не стесняйтесь предложить свое собственное улучшение для этого драгоценного камня, в конце концов, он был создан для учебных целей. Как всегда, я благодарю вас за то, что вы остались со мной и до скорой встречи!