Недавно столкнулся с забавной проблемой во время написании API при помощи grape. Grape тут на самом деле не при чем, статья скорее про то, как работает ActiveSupport, создавая всю ту магию, благодаря которой нам не нужны в rails постоянные require, и как на этом можно подорваться. Кому интересно, прошу под кат.

И так у нас есть классы — 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 посоветовал отличную статью в комментариях.

Комментарии (4)


  1. Envek
    10.08.2017 08:35
    +1

    Autoloading в рельсах давно уже признан злом, которое убивает тебя раз за разом.


    Смотрите в сторону метода require_dependency и читайте толковый официальный гайд Autoloading and Reloading Constants, раз за разом, раз за разом.


    1. printercu
      10.08.2017 11:37
      +2

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


      В дополнение к вашему совету.


      У меня основное правило — лучше его использовать для top-level констант, а для вложенных добавить require_dependency. Я пользуюсь этим модулем вместо многочисленных require_dependency. Помогает и с STI моделями, когда нужно жадно подгрузить вложенные классы.


      Хорошая статья о том, почему автолоад работает именно так.


      1. printercu
        10.08.2017 11:44
        +1

        Комментарий из статьи:


        The thing is: dependencies.rb does not pretend to emulate constant resolution algorithms. It cannot because Ruby does not pass key information, so it does not attempt to.

        dependencies.rb has to be seen in a positive way: it is a feature that has a contract, if you follow the contract, the feature works.


      1. Envek
        11.08.2017 12:10

        Спасибо за ссылку на прекрасную статью — очень доходчиво объясняет.