Ruby + hash copy — как скопировать хэш без связи с источником или почему clone, dup, merge, deep_copy(Marshal.load/dump) не работают

В очередной раз наступил на грабли копирования хэша в Ruby (в скриптах Chef), когда требуется получить именно копию, дубликат, никак не связанный с первоисточником. В своё время уже помучившись с точно такой же проблемой в Python, я был готов к такому же в тоже интерпретируемом Ruby, потому потратил на поиск решения всего пару часов. Убедившись, что ситуация аналогична и её причина очевидна - ради производительности и уменьшения потребления памяти, интерпретатор всеми способами, но даст вам не реальную копию, в том смысле, который обычно подразумевается, а, как не крути, лишь ссылку на копируемый объект. В результате, если принципиально нужно получить "чистую копию", без связей с копируемым, то остаётся лишь привычное "разобрать и собрать".

Теперь от предисловия, к коду, который имел целью улучшить сделанный в предыдущей статье, где мы использовали свой JSON для передачи переменных в Chef-рецепт. Тогда была добавлена работоспособность rb-скрипта, без передачи JSON-переменных, которые при этом брались из дефолтных значений. Сейчас же добавим возможность Передавать лишь важные значения, а тех опций, что нет, чтобы они устанавливались дефолтными. Например, вместо "полного" JSON:

{
    "awslogs_conf": {
        "SysLog": {
            "datetime_format": "%b %d %H:%M:%S",
            "file": "/var/log/syslog",
            "buffer_duration": "5000",
            "log_stream_name": "turn3.secrom.com",
            "initial_position": "start_of_file",
            "log_group_name": "SysLog"
        },
        "FS_logs": {
            "datetime_format": "%b %d %H:%M:%S",
            "file": "/var/log/turn.log",
            "buffer_duration": "5000",
            "log_stream_name": "turn3.secrom.com",
            "initial_position": "start_of_file",
            "log_group_name": "FS_logs"
        }
    }
}

 

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

{
    "awslogs_conf": {
        "SysLog": {
            "file": "/var/log/syslog",
            "log_stream_name": "turn3.secrom.com",
            "log_group_name": "SysLog"
        },
        "FS_logs": {
            "file": "/var/log/turn.log",
            "log_stream_name": "turn3.secrom.com",
            "initial_position": "start_of_file",
            "log_group_name": "FS_logs"
        }
    }
}

А для этого как раз потребуется копировать используемую структуру в хэше, т.к. атрибуты Chef в node создаются на ранней стадии и являются read-only при выполнении.

При этом попытки использовать клонирование с помощью clone:

awslogs_conf_data = node['awslogs_conf'].clone

Не подходит, т.к. clone даёт лишь ссылку на источник. В результате в коде вы будете думать, что работаете с новой структурой, но получите ошибку, которая относится не к ней, а к копируемому атрибуту node['awslogs_conf']:

Chef::Exceptions::ImmutableAttributeModification
------------------------------------------------
Node attributes are read-only when you do not specify which precedence level to set. To set an attribute use code like `node.default["key"] = "value"'

Получение дубликата с помощью dup:

awslogs_conf_data = node['awslogs_conf'].dup

Даст всё ту же ошибку «Node attributes are read-only».

Хитрый спелл в виде создания нового хэша Hash.new и присоединения merge к нему нужных элементов:

awslogs_conf_data = Hash.new.merge(node['awslogs_conf'])

Снова на выходе даст всё тот же результат, т.к. в новый объект интерпретатор Ruby снова вставит ссылку на объект источника.

Наконец, функция deep_copy, реализуемая в Ruby с помощью Marshal.load(Marshal.dump(my_hash) ):

awslogs_conf_data = Marshal.load(Marshal.dump(node['awslogs_conf']))

Казалось бы, точно дающая "настоящую копию" - должна решить проблему. Так бы оно и получилось, однако её реализация имеет ограничения,  не все объекты могут быть её скопированы, некоторые выдадут ошибку типа «can't dump hash with default proc», что и происходит в случае node у Chef-рецепта.

Наконец, повторюсь, походив по ровно таким же граблям в Python, где было также принципиально получить "именно копию" (правда в его случае словаря/dictionary), решение было получена с помощью 100% рабочего "распарсить" и "запарсить" (точней закодировать, конечно) с помощью метода JSON:

awslogs_conf_data = JSON.parse(JSON.generate(node['awslogs_conf']))

Вот теперь всё отлично, никаких ошибок. Кусок кода rb-файла Chef-рецепта для установки Amazon CloudWatcher логов в AWS -виртуалки, получился следующим:

Код:

if defined?(node['awslogs_conf'])
    Chef::Log.info("*** node['awslogs_conf'] defined and is '#{node['awslogs_conf']}' ***")
    #awslogs_conf_data = node['awslogs_conf'].clone # does not work
    #awslogs_conf_data = node['awslogs_conf'].dup # does not work
    #awslogs_conf_data = Hash.new.merge(node['awslogs_conf']) # does not work
    #awslogs_conf_data = Marshal.load(Marshal.dump(node['awslogs_conf'])) # does not work - error "can't dump hash with default proc"
    awslogs_conf_data = JSON.parse(JSON.generate(node['awslogs_conf']))
else
    Chef::Log.info("*** node['awslogs_conf'] is not defined - set awslogs_conf_data to default ***")
    awslogs_conf_data = { 'default_aws_log': default_aws_log}
end


default_aws_log = {
    "datetime_format": "%b %d %H:%M:%S",
    "file": "/var/log/syslog",
    "buffer_duration": "5000",
    "log_stream_name": "linuxcmd.ru",
    "initial_position": "start_of_file",
    "log_group_name": "SysLog"
}
if awslogs_conf_data.nil?
    Chef::Log.info("*** node['awslogs_conf'] is nil - set awslogs_conf_data to default ***")
    awslogs_conf_data = { 'default_aws_log': default_aws_log}
else
    Chef::Log.info("*** check awslogs_conf_data = '#{awslogs_conf_data}' ***")
    awslogs_conf_data.each do |log_conf_name, cur_log|
        default_aws_log.each do |key, value|
            if not defined?(awslogs_conf_data[log_conf_name][key])
                Chef::Log.info("*** #{log_conf_name}[#{key}] is not defined, set to '#{value}' ***")
                awslogs_conf_data[log_conf_name][key] = value
            elsif awslogs_conf_data[log_conf_name][key].nil?
                Chef::Log.info("*** #{log_conf_name}[#{key}] is nil, set to '#{value}' ***")
                awslogs_conf_data[log_conf_name][key] = value
            end    
        end
    end
end

Для проверки передаём в Chef Custom JSON свои переменные, не указывая все поля:

{
    "awslogs_conf": {
        "SysLog": {
            "file": "/var/log/syslog",
            "log_stream_name": "ruby.linuxcmd.ru",
            "log_group_name": "SysLog"
        },
        "FS_logs": {
            "file": "/var/log/turn.log",
            "log_stream_name": "chef.linuxcmd.ru",
            "initial_position": "start_of_file",
            "log_group_name": "RoR-logs"
        }
    }
}

И при этом всё работает:

 

 

Проверяем логи, видим:

...
[2017-03-18T08:53:46+00:00] INFO: *** node['awslogs_conf'] defined and is '{"SysLog"=>{"file"=>"/var/log/syslog", "log_stream_name"=>"ruby.linuxcmd.ru", "log_group_name"=>"SysLog"}, "FS_logs"=>{"file"=>"/var/log/turn.log", "log_stream_name"=>"chef.linuxcmd.ru", "initial_position"=>"start_of_file", "log_group_name"=>"RoR-logs"}}' ***
[2017-03-18T08:53:46+00:00] INFO: *** check awslogs_conf_data = '{"SysLog"=>{"file"=>"/var/log/syslog", "log_stream_name"=>"ruby.linuxcmd.ru", "log_group_name"=>"SysLog"}, "FS_logs"=>{"file"=>"/var/log/turn.log", "log_stream_name"=>"chef.linuxcmd.ru", "initial_position"=>"start_of_file", "log_group_name"=>"RoR-logs"}}' ***
[2017-03-18T08:53:46+00:00] INFO: *** SysLog[datetime_format] is nil, set to '%b %d %H:%M:%S' ***
[2017-03-18T08:53:46+00:00] INFO: *** SysLog[buffer_duration] is nil, set to '5000' ***
[2017-03-18T08:53:46+00:00] INFO: *** SysLog[initial_position] is nil, set to 'start_of_file' ***
[2017-03-18T08:53:46+00:00] INFO: *** FS_logs[datetime_format] is nil, set to '%b %d %H:%M:%S' ***
[2017-03-18T08:53:46+00:00] INFO: *** FS_logs[buffer_duration] is nil, set to '5000' ***
[2017-03-18T08:53:46+00:00] INFO: *** awslogs_conf_data = '{"SysLog"=>{"file"=>"/var/log/syslog", "log_stream_name"=>"ruby.linuxcmd.ru", "log_group_name"=>"SysLog", "datetime_format"=>"%b %d %H:%M:%S", "buffer_duration"=>"5000", "initial_position"=>"start_of_file"}, "FS_logs"=>{"file"=>"/var/log/turn.log", "log_stream_name"=>"chef.linuxcmd.ru", "initial_position"=>"start_of_file", "log_group_name"=>"RoR-logs", "datetime_format"=>"%b %d %H:%M:%S", "buffer_duration"=>"5000"}}' ***

...

Что таки да, пришло как:

{
    "SysLog"=>
        {
            "file"=>"/var/log/syslog",
            "log_stream_name"=>"ruby.linuxcmd.ru",
            "log_group_name"=>"SysLog"
        },
    "FS_logs"=>
        {
            "file"=>"/var/log/turn.log",
            "log_stream_name"=>"chef.linuxcmd.ru",
            "initial_position"=>"start_of_file",
            "log_group_name"=>"RoR-logs"
        }
}

После отработки получилось:

{
    "SysLog"=>
        {
            "file"=>"/var/log/syslog",
            "log_stream_name"=>"ruby.linuxcmd.ru",
            "log_group_name"=>"SysLog",
            "datetime_format"=>"%b %d %H:%M:%S",
            "buffer_duration"=>"5000",
            "initial_position"=>"start_of_file"

        },
    "FS_logs"=>
        {
            "file"=>"/var/log/turn.log",
            "log_stream_name"=>"chef.linuxcmd.ru",
            "initial_position"=>"start_of_file",
            "log_group_name"=>"RoR-logs",
            "datetime_format"=>"%b %d %H:%M:%S",
            "buffer_duration"=>"5000"

        }
}

Т.е. добавились отсутствующие поля, которые были взяты из дефолтного хэша default_aws_log.

 

Итого: чтобы скопировать hash в Ruby с целью получения "полной", "настоящей", "чистой", "не связанной с объектом источника" копии - придётся использовать затратную по ресурсам, но точно дающую результат, функцию разобрать-собрать, в роли которой в данном случае выступил метод JSON (что логично для JSON, хотя можно и другие).

п.с. Жаль, что нет такого метода изначально встроенного, кто подскажет решение получение копии хэша в Ruby без таких, всё же, по сути, извращений - буду крайне признателен.

Если вам помогла или просто понравилась статья - плюсаните/поделитесь, пожалуйста.

Добавить комментарий