mikanQT - 英語学習教材データの再設計

この記事はmikan Advent Calendar 2023の5日目の記事です。

この記事では、mikanが提供している教材学習システムの裏側について触れていますので、ご興味があれば読んでいただけますと嬉しいです。

なお、mikan Advent Calendar 2023の他の記事は下記のリンクからご覧ください。

adventar.org

mikanQTについて

mikanでは、様々な学習教材が掲載されており、例えばTOEIC®対策の教材や、英検®対策の教材などたくさんの種類があります。

TOEIC®はPart1〜Part7まで、英検®は短文の語句空所補充や長文の内容一致選択、会話の内容一致選択など、多くの問題形式が存在し、試験対策用の学習教材は特に、それらの問題形式に基づいてコンテンツが用意されていることがほとんどです。

mikanでは問題形式のことをQuestionTypeと呼んでおり、mikanの学習体験をベースにQuestionTypeを再設計したものがmikanQTです。

なぜQuestionTypeを再設計するのか

Contents QuestionTypeのツラミ

説明の都合上、TOEIC®のPart1のようなQuestionTypeを、便宜的にContents QuestionTypeとします。

これまではContents QuestionTypeをそのままの形式で利用してAPIからデータを返していました。

具体例がないと理解しづらいと思いますので、実際の入稿フローの一部を紹介します。

入稿

まず、書籍のデータをAPIで扱えるようにするため(=つまりDBに格納するため)に、入稿データを作ります。

入稿シートがあるので、Contents QuestionTypeが持つフィールド(以下、Attribute)に割り当てていきます。

入稿シートのキャプチャ

各カラムの4行目はAttributeのキーに対応しており、これらのキー名とともに、値がDBに保存されます。

API

APIはDBのデータを取り出し、必要に応じてデータを加工してレスポンスします。

for _, attr := range attrs {
        var parsedAttr domain.QuestionAttribute
        typeID := domain.QuestionTypeID(types[attr.QuestionID])
        switch typeID {
        case domain.QUESTION_TYPE_TOEIC_PART_1:
            a, err := i.parseTOEICPart1Attr(attr)
            if err != nil {
                return nil, err
            }
  // ...
}

加工処理の中では、音声や画像などのアセットのURLを作る処理や、部分的なHTMLを作るためのテンプレートパース処理など、Contents QuestionTypeに応じた細かなプログラムが書かれています。

最終的に、QuestionAttributeとしてまとめてデータを返します。

type QuestionAttribute struct {
    QuestionID string `json:"question_id"`

    /* 音声系attribute */
    /* 基本のHTMLタグのみ対応. 詳細の表示ロジックは各プラットフォーム依存 */
    AudioTextEN            string `json:"audio_text_en,omitempty"`
    AudioTextJA            string `json:"audio_text_ja,omitempty"`
    AudioSentenceEN        string `json:"audio_sentence_en,omitempty"`
    AudioSentenceJA        string `json:"audio_sentence_ja,omitempty"`
    AudioContextSentenceEN string `json:"audio_context_sentence_en,omitempty"`
    AudioContextSentenceJA string `json:"audio_context_sentence_ja,omitempty"`
    /* HTML対応attribute */
    AudioContextSentenceHTMLEN string `json:"audio_context_sentence_html_en,omitempty"`
    AudioContextSentenceHTMLJA string `json:"audio_context_sentence_html_ja,omitempty"`
    /* 音声ファイルattribute */
    /* URL */
    AudioURL         string `json:"audio_url,omitempty"`
    ResultAudioURL   string `json:"result_audio_url,omitempty"`
    CombinedAudioURL string `json:"combined_audio_url,omitempty"`
    /* ファイル名 */
    AudioFilename         string `json:"audio_filename,omitempty"`
    ResultAudioFilename   string `json:"result_audio_filename,omitempty"`
    CombinedAudioFilename string `json:"combined_audio_filename,omitempty"` // 非階層のみ: 通常のリスニング問題音声と解答のための空白時間を含めた音声ファイル

    /* Listening & Reading共通 */
    /* 基本のHTMLタグのみ対応. 詳細の表示ロジックは各プラットフォーム依存 */
    BlankedSentenceEN               string `json:"blanked_sentence_en,omitempty"`
    ContextSentenceEN               string `json:"context_sentence_en,omitempty"`
    ContextSentenceJA               string `json:"context_sentence_ja,omitempty"`
    Description                     string `json:"description,omitempty"`
    DescriptionEN                   string `json:"description_en,omitempty"`
    DescriptionJA                   string `json:"description_ja,omitempty"`
    Explanation                     string `json:"explanation,omitempty"`
    SentenceEN                      string `json:"sentence_en,omitempty"`
    SentenceJA                      string `json:"sentence_ja,omitempty"`
    RecommendedAnswerSecondsCaption string `json:"recommended_answer_seconds_caption,omitempty"` // 階層&Readingのみ: recommended_answer_secondsの引用(出典)元
    /* HTML対応attribute */
    ContextSentenceHTMLEN  string `json:"context_sentence_html_en,omitempty"`
    ContextSentenceHTMLJA  string `json:"context_sentence_html_ja,omitempty"`
    ExplanationHTML        string `json:"explanation_html"` // すべての問題タイプにおいて非空文字を返しているため、omitemptyを外している
    SentenceHTMLEN         string `json:"sentence_html_en,omitempty"`
    SentenceHTMLJA         string `json:"sentence_html_ja,omitempty"`
    SentenceHTML           string `json:"sentence_html,omitempty"`
    SentenceCommentaryHTML string `json:"sentence_commentary_html,omitempty"`
    // SentenceCommentaryHTMLを分割するために以下2つを定義
    // 詳細: https://www.notion.so/mikantechnology/Html-8892a76ec90a4ad6ba55bef569070ed3
    SentenceCommentaryEN string `json:"sentence_commentary_en,omitempty"`
    SentenceCommentaryJA string `json:"sentence_commentary_ja,omitempty"`
    ResultSentenceHTML   string `json:"result_sentence_html,omitempty"`
    ResultSentenceHTMLEN string `json:"result_sentence_html_en,omitempty"`
    ResultSentenceHTMLJA string `json:"result_sentence_html_ja,omitempty"`
    VocabularyHTML       string `json:"vocabulary_html"`

    /* 解答系attribute */
    /* 基本のHTMLタグのみ対応. 詳細の表示ロジックは各プラットフォーム依存 */
    AnswerChoice                                string   `json:"answer_choice,omitempty"`
    AnswerChoicesCommaSeparatedTextEN           string   `json:"answer_choices_comma_separated_text_en,omitempty"`
    AnswerChoicesCommaSeparatedTextJA           string   `json:"answer_choices_comma_separated_text_ja,omitempty"`
    AnswerChoicesEN                             []string `json:"answer_choices_en,omitempty"`
    AnswerChoicesJA                             []string `json:"answer_choices_ja,omitempty"`
    AnswerListSentenceHTML                      string   `json:"answer_list_sentence_html,omitempty"`
    AnswerRearrangedChoicesCommaSeparatedTextEN string   `json:"answer_rearranged_choices_comma_separated_text_en,omitempty"`
    AnswerRearrangedChoicesCommaSeparatedTextJA string   `json:"answer_rearranged_choices_comma_separated_text_ja,omitempty"`
    AnswerRearrangedChoicesEN                   []string `json:"answer_rearranged_choices_en,omitempty"`
    AnswerRearrangedChoicesJA                   []string `json:"answer_rearranged_choices_ja,omitempty"`
    AnswerSentencePartialHTML                   string   `json:"answer_sentence_partial_html,omitempty"`
    AnswerSentencePartialHTMLEN                 string   `json:"answer_sentence_partial_html_en,omitempty"`
    AnswerSentencePartialHTMLJA                 string   `json:"answer_sentence_partial_html_ja,omitempty"`

    /* 推奨時間 */
    RecommendedAnswerSeconds uint64 `json:"recommended_answer_seconds,omitempty"` // 非階層のみ: Readingのときは単なる推奨解答時間になるが、Listeningのときは `combined_audio` の再生時間となる
    /* 複数要素 */
    *QuestionMultiattribute
}

クライアント

クライアントは、必要なデータをレスポンスから取り出してViewに描画したり、回答のロジックを作ったりしています。

public struct Part1Question: ExamQuestionProtocol, Codable, Equatable {
    public let id: String
    public let orderNumber: Int
    public let displayId: String
    public let audioUrl: String
    public let imageEntities: [String]
    public var imageUrl: String {
      return imageEntities.first!
    }
    public let explanationHtml: String
    public let choices: [Exam.Choice]
    public let title = ""
    public let recommendedAnswerSeconds: Int
    public let combinedAudioUrl: URL
    // ...
}

学習画面の構成要素1

学習画面の構成要素2

お気づきの方もいらっしゃると思いますが、APIが持つモデルQuestionAttributeの各フィールドが、omitemptyとなっています。

これは、AttributeがEAV(Entity Attribute Value)パターンでテーブル設計されていることが理由です。

おそらく、教材によって同じ問題形式でも異なる表現方法があり、模索しながら抽象化していく経緯があって、スキーマを柔軟に変更できるEAVを採用したのだと推察しています。

実際に、このようなデータがテーブルに格納されています。

EAVの中身

上記の表を取得するためには下記のSQLを発行する必要があります。

SELECT
    q.id,
    qat. `name`,
    qa. `value`
FROM
    questions q
    JOIN question_attributes qa ON qa.question_id = q.id
    JOIN question_attribute_types qat ON qa.question_attribute_type_id = qat.id
    JOIN question_units qu ON q.question_unit_id = qu.id
    JOIN chapters c ON qu.chapter_id = c.id
    JOIN books b ON c.book_id = b.id
WHERE
    b.id = "01GHAZTZTAREG4SDYK9BJ7QJ20";

EAVを利用することで、以下のようなデメリットが発生していました。

  • 特定のAttributeへのNOT NULLやデータサイズなどの制約を付与できない
    • Contents QuestionTypeに必須なキーの入稿漏れに気づけない
    • 不必要に大きいデータを入稿できてしまう
  • データを取得するために多くのJOINが必要で、SQLが複雑になる
    • 入稿ミス時の状況確認や、APIで不具合が発生したときの調査が大変
  • 不整合の検知が難しい
    • キー名を間違えて入稿するなど

Contents QuestionTypeによっては、必要ないAttributeもまとめてEAVとして管理している関係でNullableな要素が多く、それをQuestionAttributeとして一つのモデルで表現したことで、クライアントのデコードエラーを引き起こしたり、入稿ミスや不具合発生時の調査を遅らせたりしていました。

さらに、提携教材が増えていくとContents QuestionTypeも増えていきます。

Contents QuestionTypeに必要なAttributeはどれで、オプショナルなAttributeはどれなのか、管理がどんどん難しくなっていきます。

各Contents QuestionTypeの型定義がないのも要因の一つでした。

また、例えば「ほとんどPart1と似た形だが微妙に異なるフィールドが必要」な教材を掲載したくなったときに、Contents QuestionTypeの再設計及び実装が必要になります。

Contents QuestionTypeの一覧

解決策としてのmikanQT

ここまでで、Contents QuestionTypeをAPIでそのまま扱ってしまうと、下記の問題があることを説明しました。

  • Contents QuestionTypeごとに必要なAttributeとオプショナルなAttributeの区別が難しかった
  • Attributeの微妙な違いで、新たにContents QuestionTypeを増やさなければならないケースがあった

EAVはともかく、教材データをもとにして入稿データを作成するところまでは良いのですが、それをそのままの形でAPIが利用していたことが問題だったため、mikanの学習体験を起点にしたQuestionTypeを作成することにしました。その理由を述べるには、既存のContents QuestionTypeの特徴を説明する必要があります。

TOEIC Part 5短文の語句空所補充を例に挙げます。

TOEIC Part5短文の語句空所補充
mikanの学習画面

これまでのContents QuestionTypeの設計の場合、問題文や解説を構成する要素が教材や試験の問題形式によって異なっていたため、別のContents QuestionTypeとして設計されていました。

これらをmikanでの学習体験から考えると、問題文があり、4択で解答ができて、解説を見れるという体験は共通になっています。

つまり、それらの体験を満たせる型を定義すれば、上述した問題をひとまず解消することができます。

続いて、mikanQTの具体例を見ていきます。

まずはGo言語の構造体の定義の例を以下に示します。

type StringComponent struct {
    Text string              `json:"text"`
    Type StringComponentType `json:"type"` // クライアントが表示方法を分岐させるためのフィールド
}

const (
    StringComponentTypePlain       StringComponentType = "plain"
    StringComponentTypePartialHTML StringComponentType = "partial_html"
    StringComponentTypeHTML        StringComponentType = "html"
    StringComponentTypeImageURL    StringComponentType = "image_url"
)

type QuestionComponentReadingUnitChoices struct {
    Result   *QuestionComponentReadingUnitChoicesResult `json:"result"`
    Sentence *StringComponent                           `json:"sentence"`
    Choices  []*QuestionChoice                          `json:"choices"`
}

type QuestionComponentReadingUnitChoicesResult struct {
    Headline *StringComponent `json:"headline"`
    Sentence *StringComponent `json:"sentence"`
}

似たような型が並んでいて少々見づらいのですが、QuestionAttributeに比べると、問題文、選択肢、解説画面など、学習に必要な要素をシンプルに表現できているかと思います。

mikanではクライアントとの通信の一部にGraphQLを利用しているため、GraphQLスキーマも定義しています。

type Question implements Node {
  id: ID!
  displayId: String!
  orderNumber: Int!
  components: QuestionComponents!
}

union QuestionComponents =
    QuestionComponentReadingUnitChoices
  | QuestionComponentReadingChoices
  | QuestionComponentListeningUnitChoices
  | QuestionComponentListeningChoices

type QuestionComponentReadingUnitChoices {
  result: QuestionComponentReadingUnitChoicesResult!
  sentence: StringComponent
  choices: [QuestionChoice!]!
}

type QuestionComponentReadingUnitChoicesResult {
  headline: StringComponent!
  sentence: StringComponent!
}

これにより、クライアントとしてもcomponents__typenameを見ることで適切なUIに切り替えることができます。

Contents QuestionTypeは学習体験ベースでmikanQTに集約され、オプショナルの問題もスキーマレベルで解消されています。

進め方

実際にmikanQTを導入していくために、いくつかのステップが必要になります。

  1. 既存のContents QuestionTypeの洗い出し
  2. クライアントの画面と、利用しているAttributeの一覧をマッピングする
  3. mikanQTのGraphQLスキーマを定義する
  4. Contents QuestionTypeごとにマイグレーションスクリプトを書く
  5. マイグレーションスクリプトを実行する
  6. ゾルバを実装する

1. 既存のContents QuestionTypeの洗い出し

まずは、mikanで取り扱っているContents QuestionTypeを洗い出す必要があります。

これはContents QuestionTypeを管理しているテーブルからデータを取得するだけで完了となります。

2. クライアントの画面と、利用しているAttributeの一覧をマッピングする

ここが一番辛い作業です。

APIで定義したAttribute名とクライアントで利用しているモデルのフィールド名に差分があったり、APIが返したデータをクライアントでさらに加工している部分があったりと、紐解くのが大変でした。

型定義書もなく、APIはほぼオプショナルで返してしまっているので、クライアントチームに多大なご協力をいただきContents QuestionTypeごとに利用しているAttributeを洗い出していただきました。

Attributeの洗い出し作業の様子
Attributeの洗い出し作業の様子

各Contents QuestionTypeごとにAttributeを洗い出したら、それぞれ似たものを集約できないかを考えてまとめていきます。

現時点では、4種類に分類することができました。

スプレッドシートでAttributeのチェック作業をしている様子
スプレッドシートでAttributeのチェック作業

3. mikanQTのGraphQLスキーマを定義する

必要なmikanQTと、それに付随するAttributeが判明したので、GraphQLスキーマ、Goの構造体を定義していきます。

実際のコードの一部は上述したたため、割愛します。

4. Contents QuestionTypeごとにマイグレーションスクリプトを書く

生のAttributeからAPIレスポンス用にデータを加工する処理がContents QuestionTypeごとに書かれているため、APIの見通しが悪くなっていました。

この問題に対し、あらかじめデータを加工した状態でデータを持っておけば、APIの仕事が減って見通しも良くなると考え、APIが行っていた処理をマイグレーションスクリプトとして実装し、加工済みデータを保存しておくことにしました。

EAVのままでは多段のJOINが必要という問題もあったため、一旦はMySQLのテーブルにJSON型で保存することでJOINなしでデータを引けるようにしました。

JSON型を利用しても、データ型や必須属性を制約としてつけられないといったEAVのデメリットはそのままですが、マイグレーションスクリプト上でも上述した構造体を参照することで型を担保しています。

また、JSON型を利用した際のパフォーマンスの懸念がありますが、SQLJSON関数を呼び出すこともなく、そのままUnmarshalされるのでDBのCPU負荷としては今のところ問題ありません。

※ただし、巨大なJSONを持つレコードがあるとクエリ応答がかなり遅くなるケースもあることが分かっているため、こちらはまた対処したいと思っています。

QuestionAttributeを返すAPIのレスポンスと、マイグレーションスクリプトによって作成されたJSONが一致することを確認しながら作業を進めました。

5. マイグレーションスクリプトを実行する

4で作成したスクリプトを、教材ごとに実行していきます。

いきなりすべての教材に対して実行せずに、部分的に実行していくことでリスクを減らします。

マイグレーションと言っても、既存のデータをもとに加工済みデータを作成し、新たなテーブルに保存しているだけなので、既存データへの変更はありません。

6. リゾルバを実装する

mikanQTのデータはJSON型で表現されているため、単純にDBからSELECTしたものをUnmarshalしていきます。

Unmarshal後は、Validメソッドを呼んでバリデーションをおこないます。

(バリデーションはUnmarshallerとして内部でコールしても良かったかもしれません。)

type QuestionComponent interface {
    IsQuestionComponent()
  Valid() error
}

func parseQuestionComponents[T QuestionComponent](raw json.RawMessage) (*T, error) {
    var components T
    err := json.Unmarshal(raw, &components)
    if err != nil {
        return nil, err
    }
  if err = components.Valid(); err != nil {
        return nil, err
    }
    return &components, nil
}

ジェネリクスを利用しているため型を渡す必要があるのですが、型は作業手順2で作成したContents QuestionTypeとmikanQTのマッピングを利用して判定します。

func ParseMikanQuestionTypeFromQuestionTypeID(questionTypeID string) (QuestionTypeID, error) {
    switch questionTypeID {
    case QUESTION_TYPE_TOEIC_PART_1.String(), QUESTION_TYPE_TOEIC_PART_2.String(), QUESTION_TYPE_LISTENING_CHOICES.String():
        return QUESTION_TYPE_LISTENING_CHOICES, nil
    case QUESTION_TYPE_TOEIC_PART_3.String(), QUESTION_TYPE_TOEIC_PART_4.String(), QUESTION_TYPE_LISTENING_UNIT_CHOICES.String():
        return QUESTION_TYPE_LISTENING_UNIT_CHOICES, nil
    case QUESTION_TYPE_TOEIC_PART_5.String(), QUESTION_TYPE_READING_CHOICES.String():
        return QUESTION_TYPE_READING_CHOICES, nil
    case QUESTION_TYPE_TOEIC_PART_6.String(), QUESTION_TYPE_TOEIC_PART_7.String(), QUESTION_TYPE_READING_UNIT_CHOICES.String():
        return QUESTION_TYPE_READING_UNIT_CHOICES, nil
    }
    return "", fmt.Errorf("ParseMikanQuestionTypeFromQuestionTypeID failed: arg = %q", questionTypeID)
}

func (q *Question) parseComponents(questionTypeID string) error {
    mikanQT, err := ParseMikanQuestionTypeFromQuestionTypeID(questionTypeID)
    if err != nil {
        // mikanQTに未対応のQuestionTypeは処理しない
        return err
    }
    switch mikanQT {
    case QUESTION_TYPE_READING_UNIT_CHOICES:
        c, err := parseQuestionComponents[QuestionComponentReadingUnitChoices](q.RawComponents)
        if err != nil {
            return err
        }
        q.Components = c
    case QUESTION_TYPE_READING_CHOICES:
        c, err := parseQuestionComponents[QuestionComponentReadingChoices](q.RawComponents)
        if err != nil {
            return err
        }
        q.Components = c
    case QUESTION_TYPE_LISTENING_UNIT_CHOICES:
        c, err := parseQuestionComponents[QuestionComponentListeningUnitChoices](q.RawComponents)
        if err != nil {
            return err
        }
        q.Components = c
    case QUESTION_TYPE_LISTENING_CHOICES:
        c, err := parseQuestionComponents[QuestionComponentListeningChoices](q.RawComponents)
        if err != nil {
            return err
        }
        q.Components = c
    }
    return nil
}

これでJSONデータをmikanQT構造体へ変換できました。

なお、JSON型で必須属性などを指定できない部分はこのようにアプリケーションで担保しています。

func (t QuestionComponentReadingUnitChoices) IsQuestionComponent() {}
func (t QuestionComponentReadingUnitChoices) Valid() error {
    if t.Result == nil {
        return errors.New("result is empty")
    }
    // 文単のバリデーション 文単の解説画面がすべてHTML対応するまで後方互換を保つ必要がある
    if t.Result.Sentence == nil {
        if t.Result.Explanation == nil {
            return errors.New("result explanation is empty")
        }
        if t.Result.Explanation.Text == "" {
            return errors.New("result explanation text is empty")
        }
        if err := t.Result.Explanation.Type.Valid(); err != nil {
            return err
        }
        if t.Result.SentenceEN == nil {
            return errors.New("result sentence_en is empty")
        }
        if t.Result.SentenceEN.Text == "" {
            return errors.New("result sentence_en text is empty")
        }
        if err := t.Result.SentenceEN.Type.Valid(); err != nil {
            return err
        }
        if t.Result.SentenceJA == nil {
            return errors.New("result sentence_ja is empty")
        }
        if t.Result.SentenceJA.Text == "" {
            return errors.New("result sentence_ja text is empty")
        }
        if err := t.Result.SentenceJA.Type.Valid(); err != nil {
            return err
        }
    } else {
        if t.Result.Sentence.Text == "" {
            return errors.New("result sentence text is empty")
        }
        if err := t.Result.Sentence.Type.Valid(); err != nil {
            return err
        }
        if t.Result.Headline == nil {
            return errors.New("result headline is empty")
        }
        if t.Result.Headline.Text == "" {
            return errors.New("result headline text is empty")
        }
        if err := t.Result.Headline.Type.Valid(); err != nil {
            return err
        }
        if len(t.Choices) <= 0 {
            return errors.New("choices must be greater than 0")
        }
    }
    return nil
}

既存データとの並行運用のため、苦し紛れで条件分岐している泥臭い部分も残っています。

現状と今後の話

ここまでの作業をバックエンドチームのサイドプロジェクトとして進めてきました。

現時点ではTOEIC®教材に対しての移行が終わり、実際にmikanQTを利用した初の機能として初回模試という機能をリリースすることもできました。

初回模試は、TOEICを目標にするユーザに向けた、実力判定のための機能となっています。

TOEIC®のContents QuestionTypeをmikanQTに移行できた一方で、まだまだやるべきことは残っています。

  • TOEIC®以外のContents QuestionTypeへの対応
  • Attributeを直接返しているAPIの廃止
  • EAVの廃止
  • 巨大なJSONを含む場合の対処

ここ数ヶ月は別のプロジェクトが進行中のため、mikanQTの対応はしばらく先になりそうです。

この取り組みはバックエンドもクライアントも幸せになれるものなので、ぜひ時間を取って移行を進めていきたいと考えています。

おわりに

様々な教材を掲載しているmikanならではの課題について書きました。

完全なコードを出さずに説明している部分もあって読みにくかったかと思いますが、少しでもバックエンドチームの取り組みが伝わっていたら嬉しいです。

現在、mikanのバックエンドチームは2名ということで、バックエンドエンジニアを募集中です。

歴史的経緯にリスペクトを持ちつつ、より良い方向へmikanを改善していけるような仲間を探しています。

EdTech、英語学習、Go言語などのキーワードにピンと来たら、ぜひ以下の求人に応募していただけたらと思います。

herp.careers

いきなり応募というのもハードルが高いかと思いますので、カジュアル面談等もお待ちしております。その場合はDMをいただければと思います。

https://twitter.com/kagagaga_ga