Недавно столкнулся с забавной проблемой во время написании API при помощи grape. Grape тут на самом деле не при чем, статья скорее про то, как работает ActiveSupport, создавая всю ту магию, благодаря которой нам не нужны в rails постоянные require, и как на этом можно подорваться. Кому интересно, прошу под кат.
И так у нас есть классы — Grape::Entity, которые мы используем для отображения модели в API, они могут наследоваться, подключать разные модули, и сами входят в различные модули при версионировании. Структура каталога выглядит примерно так:
Конкретную запись мы можем достаточно просто найти при помощи
К счастью у нас есть pry — это не только очень мощная альтернатива irb, но так же замечательный дебагер, который позволяет нам пройти по выполняемому коду, шаг за шагом, погрузившись во все методы всех вспомогательных классов, о которых мы ничего не знаем, посмотреть их реализацию, связи, и.т.д. Подробнее тут.
Итак:
И всего через пару шагов мы погружаемся в недра ActiveSupport, который парсит имя класса и ищет его реализацию, выглядит это вот так:
Что здесь происходит:
ActiveSupport разбивает нашу строку вида
И именно тут возникает проблема, когда ActiveSupport на строке 263 делает
Такой проблемы не было бы, при использовании
А еще такой проблемы не возникнет, если не наследовать классы, у которых есть вложенные классы, и не придумывать тем самым проблем самим себе )
Ps. printercu посоветовал отличную статью в комментариях.
И так у нас есть классы — Grape::Entity, которые мы используем для отображения модели в API, они могут наследоваться, подключать разные модули, и сами входят в различные модули при версионировании. Структура каталога выглядит примерно так:
./api/
L-- path1
+-- entities
¦ +-- entity1.rb
¦ L-- entity2.rb
+-- v1
¦ L-- entities
¦ +-- entity1.rb
¦ L-- entity3.rb
L-- v2
L-- entities
+-- entity1.rb
+-- entity2.rb
L-- entity3.rb
Конкретную запись мы можем достаточно просто найти при помощи
::Api::Path1::V2::Entity::Entity1
. И все хорошо, пока в этом пути присутствуют только модули, и конечный класс. Но мы не всегда работаем в одиночку, и иногда возникают вложенные классы. Module1::Module2::Class1::Class2
, это руби, здесь так можно, и в этом тоже нет ничего страшного. Но потом мы делаем новую версию нашего API, и что бы не писать все с нуля наследуем старый класс ::Api::Path1::V3::Entity::Class1::Class2
, где V3::Class1 < V1::Class1
. И вот тут все внезапно ломается. Мы пытаемся получить ::Api::Path1::V3::Entity::Class1::Class2
, а имеем ::Api::Path1::V1::Entity::Class1::Class2
. Типичный пример Rails магии, мы не получили ошибки, но не получили и нужного класса, а получили совершенно другой, и это при том, что был прописан полный путь со всеми namespaсe!К счастью у нас есть pry — это не только очень мощная альтернатива irb, но так же замечательный дебагер, который позволяет нам пройти по выполняемому коду, шаг за шагом, погрузившись во все методы всех вспомогательных классов, о которых мы ничего не знаем, посмотреть их реализацию, связи, и.т.д. Подробнее тут.
Итак:
...
binding.pry
'::Api::Path1::V3::Entity::Class1::Class2'.constantize
...
> step
65: def constantize
=> 66: ActiveSupport::Inflector.constantize(self)
67: end
> step
... @ line 251 ActiveSupport::Inflector#constantize:
248: # NameError is raised when the name is not in CamelCase or the constant is
249: # unknown.
250: def constantize(camel_cased_word)
=> 251: names = camel_cased_word.split('::')
252:
253: # Trigger a built-in NameError exception including the ill-formed constant in the message.
254: Object.const_get(camel_cased_word) if names.empty?
И всего через пару шагов мы погружаемся в недра ActiveSupport, который парсит имя класса и ищет его реализацию, выглядит это вот так:
250 def constantize(camel_cased_word)
251 names = camel_cased_word.split('::')
252
253 # Trigger a built-in NameError exception including the ill-formed constant in the message.
254 Object.const_get(camel_cased_word) if names.empty?
255
256 # Remove the first blank element in case of '::ClassName' notation.
257 names.shift if names.size > 1 && names.first.empty?
258
259 names.inject(Object) do |constant, name|
260 if constant == Object
261 constant.const_get(name)
262 else
263 candidate = constant.const_get(name)
264 next candidate if constant.const_defined?(name, false)
265 next candidate unless Object.const_defined?(name)
266
267 # Go down the ancestors to check if it is owned directly. The check
268 # stops when we reach Object or the end of ancestors tree.
269 constant = constant.ancestors.inject do |const, ancestor|
270 break const if ancestor == Object
271 break ancestor if ancestor.const_defined?(name, false)
272 const
273 end
274
275 # owner is in Object, so raise
276 constant.const_get(name, false)
277 end
278 end
279 end
Что здесь происходит:
ActiveSupport разбивает нашу строку вида
::Api::Path1::V2::Entity::Entity1
на отдельные слова, и потом последовательно собирает обратно, вызывает const_get на каждое следующее имя, начиная от родительского Object, и проверяя, что оно определено.И именно тут возникает проблема, когда ActiveSupport на строке 263 делает
::Api::Path1::V3::Entity::Class1.const_get('Class2')
, без второго параметра false оказывается что Class2 определен в наследуемом классе ::Api::Path1::V1::Entity::Class1
, и именно его мы получаем, и возвращаем из метода.Такой проблемы не было бы, при использовании
candidate = constant.const_get(name, false)
, но это скорее фича, чем баг. ActiveSupport пытается найти константу в том числе и определенную у предков, иначе магии станет гораздо меньше.А еще такой проблемы не возникнет, если не наследовать классы, у которых есть вложенные классы, и не придумывать тем самым проблем самим себе )
Ps. printercu посоветовал отличную статью в комментариях.
Envek
Autoloading в рельсах давно уже признан злом, которое убивает тебя раз за разом.
Смотрите в сторону метода
require_dependency
и читайте толковый официальный гайд Autoloading and Reloading Constants, раз за разом, раз за разом.printercu
Ну отключить то можно автолоад, и зла не будет :) Но его отсутствие в приложениях гораздо заметнее, чем в библиотеках, например. По-моему, если понять его тонкости, то он становится очень удобным.
В дополнение к вашему совету.
У меня основное правило — лучше его использовать для top-level констант, а для вложенных добавить
require_dependency
. Я пользуюсь этим модулем вместо многочисленныхrequire_dependency
. Помогает и с STI моделями, когда нужно жадно подгрузить вложенные классы.Хорошая статья о том, почему автолоад работает именно так.
printercu
Комментарий из статьи:
Envek
Спасибо за ссылку на прекрасную статью — очень доходчиво объясняет.