Это вторая статья из серии статей об уроках, извлеченных из разработки XBlocks. В этой статье будет приведен пример того, как сложность закралась в систему, потому что мы пытались сделать интерфейс XBlock более простым в использовании. Обратите внимание, что эта статья отражает мои личные выводы и мнения, и другие пользователи edX могут иметь другое мнение.

Общий обзор того, что такое XBlock, см. в первой статье этой серии. Версия TLDR: XBlock — это механизм подключаемых модулей и базовый класс для создания нового типа контента для учебных курсов Open edX, и почти весь учебный контент в курсе (например, видео, задачи) — это XBlocks.

Наш пример: автоматическое сохранение полевых данных

XBlock API позволяет вам определять поля различных типов (например, логические, целочисленные, строковые, dict и т. д.) для сохранения данных. Каждое поле имеет область действия, которая определяет, где и как оно сохраняется. Например, вы можете создать простой XBlock эссе, в котором определено поле с областью действия content для хранения вопроса, который будет предложен пользователю, и поле с областью действия user_state для хранения ответов каждого учащегося.

Когда этот XBlock создается в учебном программном обеспечении для учащегося, поле question получает свое значение из определения задачи, которое было создано в Studio и сохранено в MongoDB. Поле submission будет заполнено из таблицы в MySQL.

Допустим, вы создаете метод-обработчик save_submission, который обновляет поле пользовательской области, содержащее ответ пользователя на эссе. Это может выглядеть примерно как этот метод из блока Open Response Assessment, упрощенная версия которого может выглядеть так:

Обратите внимание, что нам не нужно было делать никаких явных вызовов, чтобы сохранить новое значение submission в базе данных. XBlock автоматически определяет, какие поля были изменены, и сохраняет их после завершения выполнения метода обработчика. Это было сделано, чтобы немного упростить процесс разработки XBlock. В конце концов, если вы внесли изменение в поле, вы почти наверняка захотите сохранить его в базе данных. Весь механизм Field защищает вас от необходимости понимать, где находятся данные, и создает иллюзию, что каждое поле — это просто простой атрибут вашего объекта XBlock.

Реализация поведения автосохранения

Итак, как мы это реализуем? Как определить, когда поле изменилось?

Первый шаг, который мы можем сделать, это переопределить метод __set__ в классе Field. Это метод, который будет вызываться каждый раз, когда мы присваиваем значение этому полю. Поэтому мы реализуем этот метод, чтобы включить некоторую бухгалтерию, чтобы отслеживать, были ли внесены изменения. Здорово!

К сожалению, это не приводит нас ко всему. Изменяемые типы могут быть изменены без вызова __set__. Предположим, у нас есть объект dict Field с именем submitted_answers:

Изменение содержимого dict не вызывает __set__, поскольку вы просто изменяете словарь на месте, а не заменяете его другим экземпляром. Чтобы решить эту проблему, у нас есть логический атрибут MUTABLE для всех типов полей. Затем мы переопределяем метод __get__, чтобы помечать изменяемые поля как грязные, если мы хотя бы возвращаем ссылку на них, потому что они могут позже измениться так, что мы не сможем легко обнаружить. Поскольку мы помечаем поле как грязное, мы также делаем его копию, чтобы позже можно было провести сравнение, чтобы увидеть, действительно ли оно изменилось. Это в основном работает, с одной оговоркой:

В приведенном выше примере мы сделали копию словаря и изменили количество попыток для этой копии. Тем не менее, он по-прежнему сравнивается с оригиналом. Причина в том, что функция copy неглубокая, поэтому copied['a'] и original['a'] теперь являются двумя отдельными ссылками на один и тот же вложенный словарь. Для безопасного копирования сколь угодно глубокой вложенности словарей нам нужно использовать более дорогую функцию deepcopy.

Оценка стоимости

Вся эта защитная deepcopy работа должна выполняться при первом доступе любого изменяемого типа Field к любому XBlock. В операциях, которые включают в себя последовательности или целые курсы, это может быть много тысяч раз. В зависимости от работы программного обеспечения курса, мы профилировали только deepcopy времени, чтобы оно составляло от 5% до 15% времени выполнения.

На самом деле усугубляет то, что подавляющее большинство этого копирования выполняется в полях области содержимого для рендеринга XBlocks в LMS, и изменения в этих полях не могут быть сохранены. LMS не разрешено записывать изменения содержимого курса, и среда выполнения LMS XBlock выдаст ошибку при любой попытке сделать это. Таким образом, мы тратим впустую все эти циклы, создавая защитные копии Fields, которые LMS никогда не изменяет и не может записывать, даже если она их изменит. Но мы делаем это, потому что поля реализованы в основной библиотеке XBlock, и этот уровень кода не понимает, что некоторые среды выполнения будут обрабатывать область содержимого как доступную только для чтения.

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

  • У нас есть бухгалтерский код и состояние для отметки доступа к полям.
  • Мы поместили эту логику в несколько непонятные методы __set__ и __get__.
  • Поля теперь должны понимать новый атрибут MUTABLE и потенциально запутанное поведение при копировании ссылок.

Даже если это не так много кода, это удивительное количество концептуальной сложности для любого, кто должен просматривать его в первый раз. Эта функция фактически вызвала несколько ошибок, связанных со значениями по умолчанию, импортом и кэшированием.

В обмен на эту сложность авторы XBlock могут вносить изменения в состояние поля без необходимости вызывать явный метод save в своих обработчиках. На более высоком уровне эти разработчики были ограждены от необходимости понимать, что их поля XBlock где-то поддерживаются базой данных — они могли обращаться с ним как с простым объектом. Важно отметить, что целевая аудитория разработчиков XBlock включала сотрудников курсов, которые не занимались программированием профессионально, и поэтому нашей целью было максимально упростить процесс запуска.

Уроки выучены

Автосохранение полей сложно, потому что оно пытается быть слишком умным на слишком низком уровне и таким образом, который плохо поддерживается языком. Если бы постоянство каким-то образом обрабатывалось на уровне обработчика — скажем, обработчику student_view был передан отдельный параметр, который вы могли бы вызывать для получения и установки состояния учащегося — тогда не было бы необходимости создавать этот интеллект на уровне отдельного поля. Даже низкоуровневый подход мог бы быть простым, если бы Python имел лучшую поддержку неизменяемых структур данных (по аналогии с некоторыми языками функционального программирования), поскольку неизменяемые словари устранили бы необходимость в защитном копировании и упростили код обнаружения изменений.

Тем не менее, слишком просто оглянуться назад и заявить, что это конкретное удобство API того не стоит. Я думаю, что более коварным аспектом является то, как, казалось бы, небольшое удобство со временем усложняется. На каждом этапе кажется, что мы затыкаем последнюю дыру в нашей дырявой абстракции, но на горизонте всегда есть что-то еще. Удобство, которое представляет поля XBlock как простые атрибуты обычного объекта Python, скрывает основную истину, и мы должны продолжать говорить более изощренную ложь, чтобы сохранить абстракцию воедино.

Так что это приводит нас туда, где мы находимся сегодня. Автосохранение опирается на пару волшебных методов и понимание изменчивости и семантики копирования в Python. Каждый когда-либо написанный XBlock предполагает автоматическое сохранение. Как решить известные проблемы с производительностью? Мы…

  1. Добавить больше сложности, чтобы включить подсказки или какое-либо другое взаимодействие API между базовой библиотекой XBlock и средой выполнения, чтобы мы не делали ненужных копий?
  2. Добавить возможность для авторов явно помечать метод как выполняемый вручную save(), чтобы мы могли внести некоторые оптимизации в этом случае, но по-прежнему использовать существующее поведение по умолчанию для обеспечения обратной совместимости?
  3. Надеюсь, мы недостаточно хорошо поработали над профилированием и посмотрим, можно ли добиться значительных успехов без какого-либо изменения поведения?
  4. Ослабить некоторые гарантии API, которые предотвращают крайние случаи, которые могут быть не важны в реальном мире (например, deepcopy и вложенные словари), и надеяться, что это не сломает слишком много вещей?
  5. Сделать новую версию API, явно нарушающую обратную совместимость?

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