Skip to content

Funfine

Romutchio edited this page Jun 10, 2019 · 1 revision

Постановка задачи

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

Решение

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

Реализация

Структура

Для взаимодействия с внешним API создана сущность генератора:

public abstract class TaskGenerator : Entity
{
    public abstract Task GetTask(Random randomSeed);
}

где Task = struct { string Text; string Question; string Answer; string[] Hints; } Для обеспечения "чистоты" генератора, генератор случайных чисел вынесен в аргумент функции, что позволяет адекватно тестировать генераторы в целом. Так же это является точкой расширения, с помощью наследования и полиморфизма возможно создать генератор, который будет работать на любом принципе, не только на шаблонах.

Генератор, работающий на шаблонах, вынесен в отдельный класс:

public class TemplateTaskGenerator : TaskGenerator
{
   public string[] PossibleAnswers { get; }

   public string Text { get; }

   public string Question { get; }
   
   public string[] Hints { get; }
   
   public string Answer { get; }
}

Шаблонный генератор одновременно является шаблоном для себя же: каждое поле в этом классе содержит текст вместе с некоторой подстановкой, которое в последствии преобразуется в объект Task с помощью движка шаблонизации.

Шаблонизация

Шаблонизация реализована с помощью библиотеки Scriban.

Введем необходимые понятия: Подстановка - некоторая строка вида "{{ smth }}. Шаблон - некоторая строка, которая может содержать в себе подстановки - "usual text {{ substitution }} usual text" Рендеринг шаблона - процесс замены подстановок сгенерированными значениями.

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

Template

"{{ var1 = 5 }} Some number is {{ var1 }} and this is incremented number {{ var1 + 1 }}"

Rendered

" Some number is 5 and this is incremented number 6"

Конкатенация

Методы библиотеки Scriban, осуществляющие парсинг и рендеринг шаблона, имеют следующую сигнатуру:

Template Parse(string rawText);

string Render(Template template, IScriptObject so);

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

public sealed class Storage : IEquatable<Storage>
    {
        private Storage(ICollection<string> items)
        {
            if (items == null)
                throw new ArgumentException("Can not split null");
            Key = Guid.NewGuid().ToString();
            Count = items.Count - 1;
            Concatenated = items.Count == 0 ? "" : Join(Key, items);
        }

        private Storage(string value, string key, int count) => (Concatenated, Key, Count) = (value, key, count);

        private int Count { get; }

        private string Concatenated { get; }

        private string Key { get; }

        [Pure]
        public static Storage Concat(params string[] items) => new Storage(items);

        [Pure]
        public string[] Split() => Concatenated?.Length == 0
                                       ? new string[0]
                                       : Concatenated?.Split(Key)?.ToArray() ?? new string[0];

Логика работы такова: строки конкатенируются, при этом в качестве разделителя используется Guid. В силу того, что его значение уникально, строку после преобразования можно разбить снова по его значению.

Итого

Метод TemplateTaskGenerator.GetTask выглядит так:

public override Task GetTask(Random randomSeed)
{
            var so = CreateScriptObject(randomSeed);

            var simpleFieldsStorage = Concat(Text, Answer, Question);
            var hintsStorage = Concat(Hints ?? new string[0]);
            var answersStorage = Concat(PossibleAnswers ?? new string[0]);

            var fields = new[] { simpleFieldsStorage, hintsStorage, answersStorage };

            var ((code, answer, question), hints, answers)
                = fields.MapMany(vs => Concat(vs).Map(s => Template.Parse(s).Render(so)).Split())
                        .Select(r => r.Split().ToArray())
                        .ToArray();
            return new Task(code, hints, answer, Id, answers, question);
}

Все поля шаблона конкатенируются, прогоняюся через парсер, рендерятся и разбиваются на исходные поля, из которых собирается Task.

Дополнительные возможности

В язык шаблонизации добавлено некоторое количество встроенных функций и переменных, которые позволяют делать более интересные и необычные задачи. Например any_of:

Шаблон

var template = 
@"{{ var = any_of ["i", "j"]                                 //| здесь в подстановке определяем глобальные переменные
    c = any_of ["c", "const", "veryImportantVariable"]       //| так как в подстановке только операции присваивания
    inc = random 2 5                                         //| то подстановка возвращает ничего и результатом рендеринга
    op = c + " += " + var + ";"                              //| будет пустая строка
}} for (var {{var}} = 0; {{var}} < n; {{var}} += {{inc}})    //| здесь используем ранее определенные переменные в шаблоне
{                                                            //|
  {{op}}                                                     //|
}"                                                           //|

Результат:

for (var i = 0; i < n; i += 4)
{
  veryImportantVariable += i;
}

Хранение в базе данных

В проекте используется паттерн "Неизменяемая структура данных", в том числе и для генераторов. В качестве БД выбрана MongoDB. К сожалению, сериализатор по умолчанию не поддерживает работу с неизменяемыми классами, для чего в документации было найдено следующее решение:

public static class MongoHelpers
{
        public static void AutoRegisterClassMap<TClass>(Expression<Func<TClass, TClass>> creatorLambda) =>
            AutoRegisterClassMap<TClass>(cm => cm.MapCreator(creatorLambda));

        public static void AutoRegisterClassMap<T>(Action<BsonClassMap<T>> additionalAction = null)
        {
            BsonClassMap.RegisterClassMap<T>(cm =>
            {
                var propertyInfos = typeof(T)
                    .GetProperties(BindingFlags.DeclaredOnly |
                                   BindingFlags.Public |
                                   BindingFlags.Instance)
                    .Cast<MemberInfo>()
                    .ToList();
                foreach (var propertyInfo in propertyInfos)
                    cm.MapMember(propertyInfo);
                additionalAction?.Invoke(cm);
            });
        }
}

Для каждого класса конфигурируется фабричный метод, которым де-факто является конструктор этого класса:

AutoRegisterClassMap<TemplateTaskGenerator>(c => new TemplateTaskGenerator(c.Id, c.PossibleAnswers,
                c.Text, c.Hints,
                c.Answer, c.Streak, c.Question));

Clone this wiki locally