Умный и безопасный рефакторинг с Scientist 🔬⚗️
Ученый — это библиотека Ruby, которая устраняет неопределенность рефакторинга. Он не предназначен для написания тестов и не предназначен для их замены. Скорее, это показывает вам как рефакторинговый код будет работать в продакшене фактически без используя его в производстве. Если вы выполняете какой-либо рефакторинг кода, который не можете позволить себе сломать, Scientist для вас.
В этом посте будет рассказано о том, как начать работу с Scientist, а также о некоторых распространенных ловушках, но сначала немного контекста.
Проблемы с рефакторингом
В любом бизнесе, от которого требуется своевременная поставка работающего программного обеспечения (например, все стартапы), рефакторинг — это трудная задача. Он не исправляет ваши ошибки и не предоставляет новых функций, поэтому его ценность совершенно невидима для ваших клиентов. И когда ваши клиенты платят вам за решение их проблем, вам может быть трудно оправдать возврат этих денег вашей команде разработчиков только за редизайн кода, который уже работает.
Но дайте мне инженера, который присоединился к проекту, который кто-то построил в спешке, и я дам вам того, кто клянется всем, что его давно пора обновить. Уже есть много отличной литературы о достоинствах рефакторинга, поэтому я не буду вдаваться в подробности (но если вам нужно немного больше убедительности, возьми это у Мартина Фаулерасам отец рефакторинга).
Проблема в том, что когда вы на самом деле садитесь делать это, всегда возникает один и тот же сбой: код, который действительно нуждается в рефакторинге, — это хрупкий, критически важный код, который никто не хочет трогать десятифутовым шестом.
Сэнди Мец описал это явление в прошлогоднем разговоре. Она называет это Антипаттерн Звезды Смертии доходит до того, что говорит, «Каждый есть эта проблема». Ее доклад предлагает принципы ООП в качестве средства правовой защиты (это хорошо, смотрите), но это решает только половину проблемы. Ваш набор тестов предполагаемый чтобы позаботиться о другой половине, а именно о том, что вы не можете позволить себе сломать код, который вы только что отрефакторили, но иногда этого просто недостаточно.
Возможно, он обращается к стороннему API так, как не может ваш набор тестов. Возможно, раньше он был медленным, а теперь нет, и снижение производительности будет стоить вам ценных клиентов. Может быть, просто больше входных данных и пограничных случаев, чем вы могли бы запланировать.
Как бы то ни было, вы попали в ловушку-22: вы не можете отправить свои изменения в рабочую среду, потому что не знаете, как они поведут себя в дикой природе; с другой стороны, вы не можете знать, как они поведут себя в дикой природе, пока не запустите их в производство. Они фактически застряли в чистилище рефакторинга.
Тут в дело вступает Ученый.
Основы
С Scientist ваша производственная кодовая база содержит как старые, так и новые версии рефакторинга кода. в то же время. Ваше приложение будет продолжать использовать старую версию (поэтому оно будет вести себя точно так же, как и раньше), но каждый раз при вызове кода оно тестирует новую версию и сообщает о любых различиях.
Применение
мертв просто:
science "My first experiment" do |experiment|
experiment.use { original_code }
experiment.try { refactored_code }
end
(Для простоты я опустил немного шаблонного кода, но вы можете найти его в ПРОЧТИ МЕНЯ.)
Конфигурация
просто немного грязнее:
class MyExperiment
include Scientist::Experiment
attr_accessor :name
def initialize(name)
@name = name
end
def enabled?
...
end
def publish(result)
...
end
end
module Scientist::Experiment
def self.new(name)
MyExperiment.new(name)
end
end
Вам нужно будет определить пользовательские реализации для #enabled?
а также #publish
. Один называется до каждый эксперимент и определяет, будет ли он вообще проводиться; другой называется после каждого эксперимента и, как вы уже догадались, публикует результаты.
Если вы работаете над проектом Rails, поместите этот материал в инициализатор.
Проверить ПРОЧТИ МЕНЯ для подробного изложения параметров конфигурации и API. А пока давайте углубимся в два метода, которые мы видели выше.
#enabled?
Самый простой подход — просто запускать эксперимент каждый раз:
def enabled?
true
end
Так почему бы вам сделать это по-другому?
Вы, вероятно, не хотите проводить эксперименты в своих средах разработки или тестирования. В приложении Rails ограничьте эксперименты постановкой/производством с помощью
def enabled? Rails.env.staging? || Rails.env.production? end
Есть другие подходы к этомуесли вы хотите получить умный.
Обе ветви вашего эксперимента выполняются на переднем плане одна за другой. Если это ресурсоемкие операции, возможно, не стоит запускать их каждый раз. Бывший главный инженер GitHub Джесси Тот объясняет:
Мы часто медленно наращиваем процент запросов, которые запускают эксперимент Scientist, если мы считаем, что эксперимент будет очень дорогим. Запуска эксперимента с 1% или 5% трафика может быть достаточно, чтобы собрать много данных о производительности и несоответствиях.
Официальная документация содержит хороший образец конфигурации для «наращивание экспериментов» этим способом.
#publish
README содержит большое пример реализации #publish
который отправляет результаты в Graphite (через StatsD) и Redis. Но если у вас нет такого механизма отчетности, настроенного вокруг вашего приложения, не беспокойтесь — нет никаких причин, по которым мы не можем адаптировать его для использования вместо этого обычных текстовых файлов.
Следующая конфигурация будет публиковать результаты в разделе log/scientist/<experiment-name>/
с данными о времени и несоответствии, хранящимися в формате YAML и ограниченными 1000 записями:
class MyExperiment
include Scientist::Experiment
attr_accessor :name
def initialize(name)
@name = name
FileUtils.mkdir_p(log_dir)
end
...
def publish(result)
store_timing_data
if result.matched?
increment('matched')
elsif result.ignored?
increment('ignored')
else
increment('mismatched')
store_mismatch_data(result)
end
end
private
def log_dir
@log_dir ||= Rails.root.join('log', 'scientist', name.parameterize)
end
def store_timing_data
fname = log_dir.join('timing.yml')
log = begin
YAML.safe_load(File.read(fname), [Symbol])
rescue Errno::ENOENT
{ control: [], candidate: [] }
end
log[:control].push(result.control.duration)
log[:candidate].push(result.candidates.first.duration)
log.values.each { |timings| timings.shift if timings.length > 1000 }
File.write(fname, log.to_yaml)
end
def increment(type)
fname = log_dir.join(type)
File.write(fname, File.read(fname).next)
rescue Errno::ENOENT
File.write(fname, "0\n")
end
def store_mismatch_data(result)
payload = {
name: name,
context: context,
control: observation_payload(result.control),
candidate: observation_payload(result.candidates.first),
execution_order: result.observations.map(&:name)
}
fname = log_dir.join('mismatch_data.yml')
log = begin
YAML.safe_load(File.read(fname), [Symbol])
rescue Errno::ENOENT
[]
end
log.push(payload)
log.shift if log.length > 1000
File.write(fname, log.to_yaml)
end
def observation_payload(observation)
if observation.raised?
{
exception: observation.exception.class,
message: observation.exception.message,
backtrace: observation.exception.backtrace
}
else
{
value: observation.cleaned_value
}
end
end
end
Ловушка: побочные эффекты
Одна из самых больших проблем с Scientist заключается в том, что он в основном сосредоточен на возвращаемые значения скорее, чем побочные эффекты (функциональные программисты объединяйтесь!). То есть успех или неудача ваших экспериментов зависит исключительно от того, насколько вы #use
а также #try
блоки (или «управление» и «кандидат») возвращают одно и то же значение (хотя есть место для конфигурации).
Это может оказаться проблематичным, например, при работе с логикой, которая отправляет электронные письма или обрабатывает платежи. Потому что Ученый выполняет оба версиях кода, должно быть верно одно из двух:
- Либо код является идемпотентным, что означает, что у ученого нет возможности проверить, действительно ли вторая ветвь эксперимента что-то сделала, либо
- код нет idempotent, и в этом случае электронные письма отправляются (или платежи обрабатываются!) дважды.
Это существенное ограничение библиотеки, хотя существует одно потенциальное обходное решение, когда рассматриваемым побочным эффектом является запись в базу данных: #try
ветвь в транзакции и выполнить откат в конце.
Ловушка: нечистые функции
Один рефакторинг, над которым я недавно работал, касался метода «счетчика»: каждый раз, когда вы его вызываете, он возвращает уникальную числовую строку, начинающуюся с текущей даты. Так, например, если вы вызвали его три раза сегодня и один раз завтра, вы получите четыре возвращаемых значения:
"201906290001"
"201906290002"
"201906290003"
"201906300001"
Технически это был частный случай побочных эффектов: номера присваивались «билетам» в системе, поэтому каждый раз #try
блокировать пробеги, счетчик будет продвигаться вперед, и номер билета будет «потерян».
Но даже без этой причуды он все равно плохо подходит для Ученого. Представьте, если бы вместо этого функция просто возвращала текущее время Unix, независимо от побочных эффектов, не было бы возможности надежно сравнить возвращаемые значения.
К сожалению, я не знаю никаких обходных путей для нечистых функций с помощью Scientist, если только ожидаемая разница между возвращаемыми значениями не может быть надежно предсказана. первый.
Подведение итогов
Несмотря на эти подводные камни, Scientist является удивительно умным инструментом для уверенного рефакторинга важных частей вашего приложения. (Есть причина, по которой он был портирован на тринадцать других языков!)
Если ваша кодовая база остро нуждается в редизайне, попробуйте. Кто знает, возможно, вам даже удастся убедить своего клиента/менеджера/и т.д. что небольшой рефакторинг стоит потраченного времени.