リファレンス処理

GitLab Flavored MarkdownにはGitLabドメインオブジェクトへの参照を処理する機能があります。これはBanzai パイプラインの2つの抽象化によって実装されています:ReferenceFilterReferenceParser 。このページでは、これらが何なのか、どのように使われるのか、そして新しいフィルタとパーサのペアをどのように実装するのかを説明します。

ReferenceFilter には、対応するReferenceParser が必要です。

2つのフィルターが(data-reference-type 属性で指定された)同じタイプのオブジェクトを見つけてリンクする場合、そのタイプのドメインオブジェクトの参照パーサーは1つで済みます。

バンザイ パイプライン

Banzai パイプラインは、パイプラインによってフィルタリングされた後のresult Hash を返します。

result Hashは各フィルタに渡され、修正されます。ここには、コンテンツから抽出された情報が格納されます。コンテナがあります:

  • パイプラインの最後のフィルターの出力に基づく、DocumentFragment または文字列 HTML マークアップの:output キー。
  • パイプラインの各フィルタによって更新された、処理の準備ができた DocumentFragmentnodes のリストを持つ:reference_filter_nodes キー。

参照フィルタ

参照を処理する最初の方法は、参照フィルターです。これは、マークアップ文書からショートコードやURI参照を識別し、それらが表すリソースへの構造化リンクに変換するツールです。

例えば、Banzai::Filter::IssueReferenceFilter というクラスは、gitlab-org/gitlab#123https://gitlab.com/gitlab-org/gitlab/-/issues/200048 といったイシューへの参照を処理する役割を担っています。

すべての参照フィルタはHTML::Pipeline::Filterのインスタンスであり、Banzai::Filter::ReferenceFilterを(間接的に)継承しています。

HTML::Pipeline::Filter #call ReferenceFilter は、適切な#call メソッドの定義を容易にするメソッドを提供します。しかし、ほとんどの参照フィルタは、これらのクラスのいずれかを直接継承しているわけではなく、AbstractReferenceFilterを継承しており、 はより高レベルのインタフェースを提供しています。

AbstractReferenceFilter AbstractReferenceFilter のサブクラスは一般的に#callをオーバーライドしません:

  • .reference_type:ドメイン・オブジェクトの型。

    これは通常キーワードで、生成されたリンクにdata-reference-type 属性を設定するために使われ、対応するReferenceParser (下記参照) とのインタラクションの重要な部分です。

  • .object_class: フィルタが参照するオブジェクトのクラスへの参照です。

    これは

    • 参照の検索に使用される正規表現を検索します。ReferenceFilter.object_symこのクラスにはReferable が含まれ、2 つの正規表現が定義されている必要があります:.link_reference_pattern.reference_pattern です。
    • .object_name を計算します。
    • .object_sym (参照パターンのグループ名)を計算します。
  • .parse_symbol(string): テキスト値をオブジェクト識別子 ( デフォルトでは#to_i ) にパースします。
  • #record_identifier(record):.parse_symbol の逆、つまりドメインオブジェクトを識別子に変換します ( デフォルトでは#id )。
  • #url_for_object(object, parent_object)ドメインオブジェクトのURLを生成します。
  • #find_object(parent_object, id): 親 (通常はProject) と識別子が与えられたら、オブジェクトを見つけます。例えば、マージリクエストの参照フィルタでは、project.merge_requests.where(iid: iid) となります。

新しい参照プレフィックスとフィルタを追加

新しいオブジェクトの参照フィルタには、^<object_type>#, because のパターンに従ったプレフィックス形式を使用します:

  1. 多様な一文字接頭辞はユーザーが追跡しにくい。特に、使用頻度の低いオブジェクトタイプでは、これは機能の価値を低下させる可能性があります。
  2. 適切な一文字接頭辞は限られています。
  3. 一貫したパターンに従うことで、ユーザーは新機能の存在を推測することができます。

名前と ID の両方を持つ新しいオブジェクトapple の参照接頭辞を追加するには、参照を次のようにフォーマットします:

  • ^apple#123 としてください。
  • ^apple#"Granny Smith" 名前で識別する場合

パフォーマンス

オブジェクトの最適化

このデフォルトの実装はあまり効率的ではありません。なぜなら、参照ごとに#find_object を呼び出す必要があり、毎回 DB クエリを発行する必要があるからです。このため、ほとんどの参照フィルタの実装では、AbstractReferenceFilter に含まれる最適化を使用しています:

AbstractReferenceFilter に含まれる最適化を使用します。#records_per_parent は、親オブジェクトからドメインオブジェクトのコレクションへのマッピングです。

このメカニズムを使用するには、参照フィルターはメソッドを実装する必要があります:#parent_records(parent, set_of_identifiers)メソッドを実装しなければなりません。

これにより、そのようなクラスは(IssuableReferenceFilter がそうであるように)#find_object を定義することができます:

def find_object(parent, iid)
  records_per_parent[parent][iid]
end

これにより、クエリの数はプロジェクトの数に比例します。私たちがparent_records メソッドを実装する必要があるのは、参照フィルタでrecords_per_parent を呼び出すときだけです。

フィルタリングノードの最適化

ReferenceFilter は、ドキュメント内のすべての<a>text() ノードを繰り返し処理します。

すべてのノードが処理されるわけではなく、ドキュメントは処理したいノードに対してのみフィルタリングされます。スキップしています:

  • リンクタグは既に以前のフィルターで処理されています (gfm クラスがある場合)。
  • 無視したい祖先ノードを持つノード(ignore_ancestor_query)。
  • 空行。
  • 空のhref 属性を持つリンクタグ。

このようなノードをReferenceFilter ごとにフィルタリングするのを避けるため、1 回だけフィルタリングを行い、その結果をパイプラインの結果ハッシュにresult[:reference_filter_nodes] として格納します。

パイプラインresult は各フィルタに渡され、ReferenceFilter がテキストまたはリンクタグを置き換えるたびに、フィルタリングされたリスト (reference_filter_nodes) が更新されます。

リファレンスパーサー

多くの場合、パフォーマンスの最適化として、Markdownを一度HTMLにレンダリングし、その結果をキャッシュし、キャッシュされた値からユーザーに表示します。例えば、これはノート、イシューの説明、マージリクエストの説明のために行われます。この結果、レンダリングされたドキュメントが、一部の後続の読者が見ることができないリソースを参照する可能性があります。

例えば、あなたがイシューを作成し、#1234あなたがアクセス #1234できる機密のイシューを参照するとします。#1234この場合、キャッシュされたHTMLには、その機密イシューへのリンクとして、ID、プロジェクトのID、その他の機密データを含むデータ属性とともに表示されます。後であなたのイシューにアクセスする読者は、issueを読む権限を持たない可能性がある #1234ため、これらの機密データを再編集する必要があります。これがReferenceParser

参照パーサは、data-reference-type 属性(参照フィルタによって設定されます)のこの関係を宣伝するリンクによって、それが扱うオブジェクトにリンクされます。これはReferenceRedactor によって、どのノードをユーザーに見せるべきかを計算するために使われます:

def nodes_visible_to_user(nodes)
  per_type = Hash.new { |h, k| h[k] = [] }
  visible = Set.new

  nodes.each do |node|
    per_type[node.attr('data-reference-type')] << node
  end

  per_type.each do |type, nodes|
    parser = Banzai::ReferenceParser[type].new(context)

    visible.merge(parser.nodes_visible_to_user(user, nodes))
  end

  visible
end

ここで重要なのはBanzai::ReferenceParser[type] で、ドメインオブジェクトのタイプごとに正しい参照パーサーを検索するために使用されます。このため、各参照パーサーは

  • Banzai::ReferenceParser 名前空間に配置されていること。
  • .nodes_visible_to_user(user, nodes) メソッドを実装してください。

実際には、すべてのリファレンスパーサーはBaseParserを継承し、定義することで実装されます:

  • .reference_type ReferenceFilter.reference_typeを定義することで実装されています。
  • そして、1つ以上の実装によって
    • #nodes_visible_to_user(user, nodes) 最高級の粒度制御のために
    • #can_read_reference? nodes_visible_to_user をオーバーライドしない場合に必要。
    • #references_relation IDによるオブジェクトのアクティブレコード関係。
    • #nodes_user_can_reference(user, nodes) を使用してノードを直接フィルタリングできます。

各参照タイプに対してこのクラスを実装しないと、アプリケーションはMarkdown処理中に例外を発生させます。