Skip to content

LOG: Anton

Romutchio edited this page Jun 10, 2019 · 3 revisions

1. Проблема

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

Задача

Цель

Создать такую викторину, которая будет лишена описанных выше проблем.

  • Вопросы будут новыми для каждого пользователя сервиса, таким образом пользователь будет осознавать и обдумывать каждый вопрос, в то же время сохраняя для себя интерес продолжать.
  • Каждый пользователь будет играть и отвечать на вопросы столько, сколько понадобится, а сервис поймет, когда материал усвоен.

Реализация

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

2. Постановка своей собственной задачи

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

3. Описание архитектуры решения

Сервисы

Решение построено из набора микросервисов, взаимодействующих между собой по некоторому протоколу - в основном HTTP. Это позволяет добиться стабильности, более простой и независимой тестируемости. Кроме того, каждый разработчик получает возможность работать над сервисом независимо, сохраняя лишь внешнее API.

Архитектура

Core

Здесь находится бизнес-логика викторины, построенная согласно DDD:

DDD

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

Task API

Внешнее REST API для работы с задачами и уровнями. Построено на фреймворке ASP.NET Core.

Quiz API

Внешнее REST API для работы с викториной и пользователями. Построено на фреймворке ASP.NET Core.

Quiz Database

Интерфейс для работы с MongoDB: заданиями, пользователями и другой статистикой.

Telegram Bot

Построенный на ASP.NET Core webhook-bot для Telegram.

Telegram users database

Интерфейс для работы с MongoDB: состояниями пользователей и их аутентификационными данными для Quiz API.

Editor

Веб-сервис для редактирования и дизайна уровней викторины. Фронтенд на React JS, бекэнд на ASP.NET Core

4. Подробное описание своего участка работы.

Структура

Для взаимодействия с внешним 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));

5. Скриншоты, демонстрирующие работу.

  • Приветственное сообщение
  • Обратная связь (вызов посредством команды /feedback)
  • Выбор уровня
  • Отображение задачи
  • Сообщение после прохождение уровня
  • Пользователь нажал на кнопку "Пожаловаться на задачу" и сообщил об ошибке:
  • После репорта, сообщение прилетает в специальный чат в таком виде:

6. Литература:

Языки, грамматики, распознаватели - А. П. Замятин, А. М. Шур, 2007

Telegram Bots Book @ https://telegrambots.github.io/book/

ASP.NET Core in Action - Andrew Lock, 2018