Привет, меня зовут Билл «LtRandolph» Кларк. Я работаю техническим руководителем команды создания чемпионов LoL. За последние несколько лет я успел поработать в разных отделах разработки League, но единственное, чем я был постоянно одержим — это технический долг. Мне нужно найти его, понять его и, при возможности, устранить его.
Когда разработчики обсуждают любую существующую технологию, например патч 8.4 League of Legends, то часто упоминают технический долг. Я называю техническим долгом код или данные, за которые придётся расплачиваться будущим разработчикам. Этой печальной стороне разработки ПО посвящено бесчисленное количество постов, статей и определений. В своём посте я хочу обсудить виды технического долга, с которыми мне пришлось встретиться при работе в Riot, и рассказать о модели, которую мы начали использовать в компании. Если бы меня попросили выделить самый важный урок, который можно извлечь из этой статьи, то я сказал бы, что это описанная ниже метрика «инфицирования».
Метрики
Чтобы принимать правильные решения о том, какие проблемы необходимо устранить сейчас, а какие можно ставить на потом (или, будем реалистичными, совершенно забыть о них), нам нужен какой-то способ измерения каждого конкретного элемента технического кода. Я выбрал для оценки три основные оси измерения: влияние, затраты на устранение и инфицирование.
Влияние
Первая ось — самая очевидная: влияние долга. Оно принимает вид проблем, с которыми сталкиваются игроки (баги, отсутствующие функции, неожиданное поведение) и разработчики (более медленная реализация, проблемы с рабочим процессом, произвольная бесполезная чушь, которую приходится запоминать). Стоит заметить, что под «разработчиком» здесь подразумевается любой создатель игры, работающий над любым её аспектом. С частью технического долга приходится встречаться программистам, пишущим новый код, другие части мешают дизайнерам создавать новые скрипты, некоторые не позволяют художникам по эффектам делать новые системы частиц, и так далее.
Затраты на устранение
Вторая ось связана с затратами на избавление от технического долга. Если мы решаем устранить проблему в коде или данных, то на это потребуется некое измеряемое количество времени. Если это глубоко укоренившееся допущение, влияющее на каждую строку кода в игре, то на него могут уйти недели или месяцы времени разработчиков. Если это глупая ошибка в одной функции, то её можно исправить за считанные минуты. Вне зависимости от учёта времени на реализацию исправления нам также нужно принимать во внимание риск внедрения такого исправления. Даже систему, которую я считаю «плохой», можно использовать как инструмент для создания замечательной игры. Если я изменю способ обработки ошибок скриптовым движком или вычисления частицами времени своего создания, то это может разрушить поведение более чем 500 заклинаний 140 с лишним чемпионов игры.
Инфицирование
На третьей оси измеряется то, чем я одержим, а именно инфицирование. Если позволить техническому долгу продолжить существование, то как далеко он распространится? Такое распространение может возникнуть из-за того, что другие системы взаимодействуют с затронутой долгом системой, из-за копипастинга данных, созданных поверх системы, или из-за того, что он повлияет на способ реализации другими разработчиками новых функций.
Если фрагмент технического долга хорошо ограничен, то затраты на его устранение в будущем практически одинаковы с текущими. Рассматривая необходимость исправления, мы можем взвесить его влияние сегодня. С другой стороны, если фрагмент долга очень заразен, то его постепенно будет всё сложнее и сложнее устранить. Особенно отвратительно в заразном техническом долге то, что его влияние склонно к разрастанию, когда всё больше систем инфицируются техническими компромиссами, лежащими в его основе.
Виды долга
Теперь, когда у нас есть система измерения каждого конкретного элемента технического долга, давайте обсудим некоторые общие категории технического долга, которые я замечал в League of Legends.
Локальный долг
Локальный долг напоминает классическую модель «чёрного ящика» программирования. Пока дело касается остальной части игры, локальная система (заклинания, сетевой слой, скриптовый движок) выглядит довольно надёжной. Никто не должен держать в уме долг, пока выполняет разработку, не касаясь системы. Но если кто-нибудь откроет крышку и взглянет внутрь, то будет поражён тем, что увидит.
Пару примеров локального долга из реального мира можно найти в наших собственных глазах. Из-за особенностей строения глаза мы видим всё вверх ногами. Что более важно, нерв сетчатки создаёт в середине каждого глаза слепое пятно. Эти искажённые данные передаются в визуальные центры
Один из наиболее знаменитых примеров локального долга в League of Legends — это Катаклизм Джарвана, до сих пор состоящий из миньонов. Когда дизайнерам нужно привязать геймплейные эффекты к точке (или нескольким точкам), то одним из доступных им инструментов является возможность создания «невидимого миньона». То, что я называю «миньоном», RiotXypherous описывает здесь. Эти игровые объекты являются стабильным и хорошо понятным способом отслеживания и выполнения скриптовой логики. В случаях наподобие стены Джарвана необходимо создавать большое количество миньонов (если точно, то 24), чтобы гарантировать, что никто не сможет протиснуться сквозь стену. Альтернативным решением может быть кольцевая конструкция рельефа, состоящая из единого логического элемента, контролирующая возможность прохождения сквозь Катаклизм. Если мы воспользуемся таким подходом, то сможем подчистить логику и слегка снизить вычислительные затраты. Давайте рассмотрим Катаклизм в нашей модели влияния/затрат/инфицирования, чтобы понять, почему его исправление в настоящий момент не является наилучшим вариантом.
Метрики Катаклизма
1. Влияние: 1 / 5
Раньше, когда создавалось 12 миньонов, люди иногда могли протиснуться сквозь стену, поэтому Riot Exgeniar увеличил их количество до 24. То, что стена создана из миньонов, почти никогда не влияет на других разработчиков в процессе изготовления нового контента. (Небольшой отступление: печально известный "Ult Hitch Джарвана" был вызван сочетанием этого долга и бага загрузки, вызванного попыткой считать отсутствующие определения автоматических атак.)
2. Затраты на устранение: 2 / 5
Пока у нас нет возможности составлять фигуры для создания произвольной геометрии без написания нового кода. Если бы мы хотели создать кольцо для реализации «триггера области», чтобы барьер Джарвана работал более эффективно, то нам пришлось бы писать специальные математические вычисления для расчётов коллизий с кольцом. Мы используем конструктивную сплошную геометрию для других целей, что может решительным образом снизить затраты на исправление.
3. Инфицирование: 1 / 5
Никто не должен принимать во внимание реализацию стены Джарвана при разработке новых возможностей, поэтому она хорошо ограничена. Единственный риск инфицирования заключается в том, что другие дизайнеры могут скопипастить эту реализацию в своих новых чемпионов (что время от времени случается). Но как бы далеко ни заходили проблемы реализации, потенциальное распространение Катаклизма низко и хорошо понятно.
Это довольно типичный вид локального долга. Чаще всего локальный долг характеризуется низкой оценкой инфицирования. Если влияние выше, чем затраты на устранение, то обычно долг устраняется сознательным разработчиком до того, как станет слишком поздно.
Принимая решение о необходимости устранения локального долга, сначала задайте себе вопрос: стоит ли он того? Если долг на самом деле не заразен, то будет достаточно безопасно оставить его в покое на любое необходимое время. Одна из самых больших ошибок, которые я встречаю — это инстинктивное желание расправиться с локальным долгом, вызываемое перфекционизмом разработчика, когда на самом деле влияние долга не оправдывает вложенных усилий. Если вы решаете внести исправление, то благодаря локальности изменений исправление и регрессионное тестирование обычно выполняются легко.
В число недавних примеров устранения локального долга входят баги с замедлителями, заставляющие чемпионов в определённых условиях прокладывать себе путь к координате 0,0,0, Муссон Жанны, игнорирующий щиты заклинаний, и стек вызовов Слезы богини без затрат маны.
Долг Макгайвера
Долг Макгайвера назван по названию телесериала середины 80-х. Ангус Макгайвер решал проблемы с помощью своего швейцарского армейского ножа, изоленты и найдённых под рукой предметов.
В его решениях часто использовалось соединение двух неожиданных частей; в контексте технического долга это означает, что две конфликтующие системы скрепляются друг с другом «изолентой» в местах их взаимодействия в кодовой базе.
В Сиэтле (как и во многих других городах) есть печальный пример описанного выше долга Макгайвера. В городе было два конкурирующих поселения, каждое со своей сеткой кварталов. Когда эти поселения выросли в современный Изумрудный город, то немного отличающиеся сетки были объединены, что привело к ужасным формам кварталов и зданий, а также совершенно неэффективному использованию пространства. Мне особенно удивляет этот небольшой срезанный угол здания в левом нижнем углу.
Один из лучших примеров долга Макгайвера в кодовой базе LoL — использование std::string из C++ наряду с нашим собственным классом AString. Оба из них являются способами хранения, изменения и передачи строк символов. В общем случае мы обнаружили, что std::string ведут ко множеству «скрытых» размещений памяти и вычислительных затрат, кроме того, с ними легко писать код, делающий плохие вещи. AString был специально разработан с учётом продуманного управления памятью. Наша стратегия замены std::string на AString заключалась в том, что мы позволили им обоим существовать в кодовой базе и обеспечивать преобразования между двумя типами (с помощью, соответственно, .c_str() и .Get()). Мы внесли в AString множество простых в реализации улучшений, позволивших нам удобнее работать с ним, и стимулировали разработчиков не спеша заменять std::string в процессе изменения кода. Таким образом, мы постепенно вытесняли std::string, и «скотчевый» интерфейс между двумя системами постепенно сужался.
Метрики std::string против AString
1. Влияние: 2 / 5
На данный момент большинство сильно влияющих размещений памяти std::string было вытеснено с помощью профилирования, поэтому основными затратами сейчас является небольшое мыслительное усилие для переключения с одной системы на другую.
2. Затраты на устранение: 3 / 5
Преобразование в AString не было просто задачей «найти и заменить». В AString есть несколько аспектов для различных целей (в дополнение к базовому AString с выделением динамической памяти есть AStackString для изначального расположения в памяти стека и ARefString для ссылок на статичные строки). Для правильной реализации на точку замены должен посмотреть настоящий, мыслящий человек. Вытеснение старой системы будет долгим и медленным процессом.
3. Инфицирование: -2 / 5
Сделав AString проще в работе, чем std::string, мы на самом деле обернули заражение в свою пользу. Каждый раз, когда разработчик вносит изменение в код игры, существует вероятность того, что AString как вирус распространится дальше.
Обычно наибольшими затратами от долга Макгайвера являются интеллектуальные, необходимые для переключения режимов
При рассмотрении необходимости устранения долга Макгайвера стремитесь находить способы сделать более качественную систему (глобальную) желанной на локальном уровне. Если ограниченный по времени разработчик, реализующий в повседневной работе жадные оптимизации, решает перейти к желательному конечному состоянию, то вы находитесь на верном пути.
Другой подход, который может сработать, заключается в крупномасштабном рефакторинге грубым перебором. При близкой связи систем есть возможность устранения части или полного долга Макгайвера с помощью хитрых regex.
Фундаментальный долг
Фундаментальный долг — это когда некое допущение лежит очень глубоко в сердце системы и неразрывно связано со всей её работой. Опытным пользователям системы иногда сложно распознать фундаментальный долг, потому что он кажется чем-то «естественным».
Смехотворно глупым примером фундаментального долга в реальном мире является система измерений, известная как американская система мер. Я рос в США, и мой
Мы говорили о некоторых самых больших фрагментах фундаментального долга, с которым борется Riot, в предыдущих статьях нашего технического блога, например, в Determinism in League of Legends и Game Data Server.
Ещё одним примером фундаментального долга, о котором я много думаю, является использование скриптового языка Lua. Дизайнеры League используют инструмент под названием BlockBuilder для создания сложных поведений, соединяя вместе функциональные блоки, например, получение расстояний между точками, создание миньонов, нанесение урона, или работая над управлением выполнения скриптов. Набор операций, из которых могут выбирать дизайнеры, достаточно велик, но ограничен, а параметры каждой операции минимальны. Тем не менее, много лет назад, в доисторическую эпоху League of Legends, было принято решение не хранить блоки и параметры в простом, ограниченном формате, соответствующем данным. Вместо этого их стали хранить как массивы и таблицы на мощном, красивом и чрезвычайно сложном для этой цели языке Lua. Прошло десятилетие после принятия этого решения, и сегодня одними из самых частых операций в движке является манипулирование объектами Lua.
Метрики BlockBuilder Lua
1. Влияние: 4 / 5
Несоответствие между lua и этим пространством задач имеет множество затрат. Каждый стек вызовов загрязнён в среднем шестью упорядоченными кадрами стека для каждого кадра логики BlockBuilder. Эти упорядоченные операции недёшевы с точки зрения использования ЦП сервера. Считывание различий в изменениях скриптов оказывается необоснованно сложной задачей. Парсинг/поиск по скриптовым файлам для определения их функционала требует довольно глубокого понимания языка Lua.
2. Затраты на устранение: 4 / 5
Поскольку Lua так глубоко внедрён в движок, его «откапывание» было бы сложной задачей. В настоящее время есть предложение создать класс-обёртку, ведущий себя как объекты Lua, но с гораздо более простой внутренней структурой, чтобы мы могли постепенно преобразовывать внутренности скриптов в нечто более подходящее. Но как бы мы ни подошли к решению этой задачи, нам необходимо быть внимательными и вдумчивыми.
3. Инфицирование: 4 / 5
Каждый раз, когда система сталкивается со скриптингом (который является базовой единицей логики LoL), эта система обуславливается операциями и требованиями Lua-бэкенда. В среднем мы создаём новый строительный блок каждые 3-4 дня. Каждый из них напрямую манипулирует объектами Lua. Чем дольше мы не заменяем Lua, тем сложнее становится его заменить.
Обычно фундаментальный долг имеет высокие показатели по всем трём осям. Высокие затраты заставляют придерживаться устаревшей системы, что часто является правильным решением, но высокое влияние и высокое инфицирование означают, что исправление вопиющего фундументального долга будет многократно вознаграждено.
Чаще всего в Riot стратегией устранения фундаментального долга является построение новой системы рядом со старой. Если это возможно, то я рекомендую преобразовывать фундаментальный долг в долг Макгайвера, постепенно портируя системы на использование новой системы с возможностью операций преобразования между новой и старой системами. Это позволяет начать легко пользоваться преимуществами в целевых областях, снижая при этом подверженность риску. Однако иногда такое преобразование невозможно. В таком случае создание перехода во время компилирования (или, если это возможно, во время загрузки) позволяет набраться уверенности в новой системе, не ставя при этом на карту всё. Схема со компилированием используется в преобразовании GDS, а схема с загрузкой сработала для детерминированности.
Долг данных
Долг данных начинается с фрагмента технического долга одной из других категорий. Это может быть баг в скриптовой системе, не очень желательный файловый формат для предметов или две системы, не очень хорошо взаимодействующие друг с другом. Но затем поверх этого изъяна кода создаётся куча контента (графики, скриптов, звуков и т.д.). Вскоре исправление изначального технического долга становится невероятно рискованным, и оказывается очень сложно сказать, что может сломаться при попытке всё починить.
Мой любимый пример из реального мира для понимания долга данных — это ДНК. Геном — это организм, медленно росший в течение миллионов лет через копирование с потерями (мутациями), ошибками транскрипции и давлением эволюции. Некоторые ошибки копирования бесполезны, но безобидны, другие вредны, а иные дают огромные преимущества. Выяснить, что же делает на самом деле каждый фрагмент ДНК, невероятно сложно. Мы полностью понимаем, что значат базовые пары, и как наборы базовых пар преобразуются в аминокислоты для создания белков. Мы даже начинаем понимать, некоторые из ролей, которые может играть ДНК, кроме кодирования. Но в трёх миллиардах базовых пар генома человека по-прежнему слишком много того, чего мы даже отдалённо не понимаем. Эпизод Radiolab о CRISPR рассказывает о том, как удалось решить одну из таких головоломок.
Долг данных в League of Legends оказывает самое сильное влияние тогда, когда он превращает тривиальное исправление в выматывающее испытание. Я расскажу только об одном небольшом примере, но можете поверить: долг данных — один из самых важных причин внесения изменений в движок LoL. Наши игровые разработчики обладают глубинными знаниями о реализации игровых систем и имеют достаточно навыков, чтобы прогнозировать, какие данные могут сломаться при изменении каких-нибудь фрагментов кода.
Незабываемый пример долга данных, исправленный несколько лет назад, был связан с параметрами блоков в нашем скриптовом языке BlockBuilder. На изображении выше показан пример того, как я увеличиваю значение брони Owner на переменную плюс константу. Я ожидаю, что Owner получит бонус к броне 25 единиц: 20 от переменной Delta, которая передаётся в блок, и 5 от константы. Однако из-за того, что имя переменной соответствует имени параметра, это действие прибавляло 40 единиц. (Даже не спрашивайте, почему не 45; я понятия не имею, какой мыслительный процесс к этому привёл.)
Когда разработчик из команды создания чемпионов NoopMoney приступил к исправлению этого нелепого поведения, ему достаточно было удалить четыре строки кода. Но в случае такого сильно заразного долга даже для небольших изменений требуется тщательное планирование. Этим багом могли удваиваться любые численные параметры 400 тысяч строк скриптов LoL. Хуже того, эти скрипты «вели себя хорошо» в том смысле, что игра сбалансирована и настроена относительно этих возможно удвоенных значений. NoopMoney должен был сделать так, чтобы исправление можно было отключать в реальном времени (на случай возникновения неожиданных багов), а также выполнить подробный поиск regex и нагрузить работой отдел контроля качества, чтобы определить, какие скрипты работают правильно благодаря этому багу. В конце концов проблемы от исправления этого бага оказались довольно незначительными; потребовалось изменение скриптов всего небольшой группы чемпионов. Но из-за долга данных их оказалось трудно спрогнозировать.
Метрики бага наименования параметров
1. Влияние: 2 / 5
Возникновение этого бага мало влияло на игру. Он удваивал передаваемое значение и имел вероятность отбрасывания константы. Но он стал ещё одной крупицей бесполезных коллективных знаний, которые должны были учитывать дизайнеры и разработчики (после того, как узнавали о них). Внимание разработчика — слишком ценный ресурс, чтобы разбрасываться им таким образом.
2. Затраты на устранение: 2 / 5
Как я сказал, процесс исправления оказался простым. Создав функцию отката исправления в реальном времени, мы смогли повысить уверенность в его безопасности. Самой затратной частью был первоначальный анализ с оценкой масштабов проблемы для целевого тестирования.
3. Инфицирование: 4 / 5
Неудачно в этом баге было то, что он основывался на очень логичном поведении. Например, если вы хотите нанести урон юниту, то совершенно логично хранить значение в переменной Damage. Увы, в блоке ApplyDamage, который получал это значение, был параметр с таким же названием, что приводило к багу. Затем, когда кто-то другой хотел создать похожее заклинание, он просто копипастил эти блоки, распространяя баг дальше.
Обычно затраты на устранение долга данных высоки, потому что сложно оценить изменения. Опаснее то, что он почти всегда чрезвычайно заразен из-за некоторых свойств данных (в отличие от кода). Во-первых обычно считается приемлемым создавать новый элемент данных копипастингом уже существующего. Если вы делаете новое заклинание скилшота, то можно сэкономить много времени, взяв за основу Мистический выстрел Эзреаля. Все проблемы с существующим элементом данных распространяются на его потомков. Во-вторых, в отличие от кода, данные редко подвергаются техническому анализу. Поэтому сложно заметить и остановить распространение ошибочных практик, даже если они хорошо известны. Наконец, для исправления ошибок в данных обычно нужен человек с глазами и
Для устранения долга данных я видел два основных подхода. Первый я называю флажком «делай правильно». Для создателей данных это означает переход от старого «сломанного» поведения к новому «исправленному» поведению. В идеале после того, как выясняется, что старый контент использует сломанную версию, исправленная версия должна становиться версией по умолчанию. Затем, как и в случае с долгом Макгайвера, можно заняться медленной и постепенной заменой для перехода на новую версию. При этом возникают постоянные затраты на добавление всё большего и большего количества чуши в UI редактора.
Второй подход я называю «просто исправь ошибку». Им пользовался NoopMoney при устранении бага с именами параметров. Он подразумевает исправление ошибки и ремонт всех данных, на которые она повлияла. Чтобы эта задача была не такой устрашающей, можно использовать некоторые техники. Сначала нужно выполнить множество операций поиска grep и regex, чтобы постараться оценить теоретическое влияние бага. Во-вторых, провести целевое тестирование. Наконец, можно подготовить функцию переключения для возврата к старому поведению после внедрения исправления на случай, если вы упустили что-то похуже, чем исправляемый баг. Стоит также заметить, что в тестировании таких видов изменений нам очень помогла детерминированность. Она позволила нам убедиться, что сервер обеспечивает одинаковые результаты до и после изменений.
Подведём итог
При оценке примера технического долга можно использовать метрики влияния (на пользователей и разработчиков), затрат на устранение (временные и степени риска), а также инфицирования. Полагаю, большинство разработчиков регулярно оценивает влияние и затраты на устранение, но я редко встречал обсуждения инфицирования. Когда проблема становится глубже и её всё труднее и труднее устранить, инфицирование может стать самым серьёзным врагом разработчика. Однако иногда можно превратить инфицирование в собственное оружие, сделав исправление более заразным, чем проблема.
При работе над League бОльшая часть наблюдаемого мной технического долга относится к одной из этих четырёх категорий. Локальный долг похож на чёрный ящик с отвратительным содержимым. В долге Макгайвера две или более системы скреплены скотчем и дополнены функциями преобразования. При фундаментальном долге вся структура построена на неких неудачных допущениях. В долге данных огромные объёмы данных наслаиваются на какой-то другой вид долга, что делает его исправление рискованным и длительным.
Надеюсь, этот пост предоставит вам полезную пищу для размышлений и обсуждения технического долга.
Автор: PatientZero