참조 처리
GitLab v19.4요약
GitLab Flavored Markdown은 다양한 GitLab 도메인 객체에 대한 참조를 처리하는 기능을 포함합니다. 각 ReferenceFilter에는 대응하는 ReferenceParser가 있어야 합니다. 참조 파서는 필터 간에 공유할 수 있습니다.
GitLab Flavored Markdown은 다양한 GitLab 도메인 객체에 대한
참조를 처리하는 기능을 포함합니다. 이 기능은 Banzai 파이프라인의 두 가지 추상화인
ReferenceFilter와 ReferenceParser로 구현되어 있습니다. 이 페이지에서는 두 추상화가
무엇이고 어떻게 사용되는지, 그리고 새 필터와 파서 쌍을 어떻게 구현하는지
설명합니다.
각 ReferenceFilter에는 대응하는 ReferenceParser가 있어야 합니다.
참조 파서는 필터 간에 공유할 수 있습니다. 두 필터가 (data-reference-type 속성으로
지정되는) 같은 유형의 객체를 찾아 링크한다면, 그 유형의 도메인 객체에는 참조 파서가
하나만
있으면 됩니다.
Banzai 파이프라인#
Banzai 파이프라인은 파이프라인에서 필터링을 거친 뒤 result 해시를 반환합니다.
result 해시는 수정을 위해 각 필터에 전달됩니다. 필터는 콘텐츠에서 추출한 정보를 이 해시에 저장합니다.
해시에는 다음이 들어 있습니다:
- 파이프라인 마지막 필터의 출력에 기반한 DocumentFragment 또는 문자열 HTML 마크업을 담은
:output키. - 처리할 준비가 된 DocumentFragment
nodes목록을 담은:reference_filter_nodes키이며, 파이프라인의 각 필터가 업데이트합니다.
참조 필터#
참조를 처리하는 첫 번째 방식은 참조 필터입니다. 참조 필터는 마크업 문서에서 숏코드와 URI 참조를 식별해, 그것이 가리키는 리소스에 대한 구조화된 링크로 변환하는 도구입니다.
예를 들어
Banzai::Filter::References::IssueReferenceFilter
클래스는 gitlab-org/gitlab#123이나
https://gitlab.com/gitlab-org/gitlab/-/issues/200048 같은 이슈 참조를 처리합니다.
모든 참조 필터는 HTML::Pipeline::Filter의 인스턴스이며,
Banzai::Filter::References::ReferenceFilter를 (대개 간접적으로) 상속합니다.
HTML::Pipeline::Filter는 현재 문서를 변경하는 void 메서드 #call 하나로 이루어진
단순한 인터페이스를 가집니다. ReferenceFilter는 적절한 #call 메서드를 더 쉽게
정의할 수 있는 메서드를 제공합니다. 다만 대부분의 참조 필터는 이 두 클래스를 직접
상속하지 않고, 더 상위 수준의 인터페이스를 제공하는
AbstractReferenceFilter
를 상속합니다.
AbstractReferenceFilter의 서브클래스는 보통 #call을 재정의하지 않습니다. 대신
AbstractReferenceFilter를 구현할 때 최소한 다음을 정의해야 합니다:
-
.reference_type: 도메인 객체의 유형입니다.보통 키워드이며, 생성되는 링크의
data-reference-type속성을 설정하는 데 사용되고, 대응하는ReferenceParser와 상호작용하는 데에도 중요한 역할을 합니다(아래 참고). -
.object_class: 필터가 참조하는 객체의 클래스에 대한 참조입니다.다음 용도로 사용됩니다:
- 참조를 찾는 데 사용하는 정규식을 찾습니다. 해당 클래스는
Referable을 포함해야 하며, 그에 따라.link_reference_pattern과.reference_pattern두 정규식을 정의해야 합니다. 두 정규식 모두ReferenceFilter.object_sym값과 같은 이름의 명명된 캡처 그룹을 포함해야 합니다. .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:identifier] 패턴을 따르는 형식을
사용합니다. 그 이유는 다음과 같습니다:
- 한 글자짜리 접두사가 제각각이면 사용자가 기억하기 어렵습니다. 사용 빈도가 낮은 객체 유형이라면 특히 기능의 가치가 떨어집니다.
- 적합한 한 글자 접두사는 수가 제한적이며, 새 참조에는 더 이상 허용되지 않습니다.
- 일관된 패턴을 따르면 사용자가 새 기능의 존재를 유추할 수 있습니다.
확장 가능한 참조 필터 에픽에서 이 형식의 사용을 다루고 있습니다.
이름과 ID를 모두 가진 새 객체 apple에 참조 접두사를 추가하려면 참조를 다음과 같이
작성합니다:
- ID로 식별할 때는
[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마다 이런 노드를 필터링하지 않도록, 필터링은 한 번만 수행하고 그 결과를 파이프라인의 result 해시에 result[:reference_filter_nodes]로 저장합니다.
파이프라인 result는 수정을 위해 각 필터에 전달되므로, ReferenceFilter가 텍스트나 링크 태그를 교체할 때마다 필터링된 목록(reference_filter_nodes)이 갱신되어 다음 필터가 사용합니다.
참조 파서#
성능 최적화를 위해 Markdown을 HTML로 한 번만 렌더링하고 그 결과를 캐시한 다음, 캐시된 값을 사용자에게 보여 주는 경우가 많습니다. 예를 들어 노트, 이슈 설명, 머지 리퀘스트 설명이 그렇습니다. 그 결과, 렌더링된 문서가 이후 일부 독자에게는 보이면 안 되는 리소스를 참조하고 있을 수 있습니다.
예를 들어 이슈를 만들면서 본인이 접근 권한을 가진 기밀 이슈 #1234를 참조할 수
있습니다. 이는 캐시된 HTML에서 해당
기밀 이슈로 연결되는 링크로
렌더링되며, 데이터 속성에는 그 이슈의 ID, 프로젝트 ID, 그 밖의 기밀 데이터가
담깁니다. 나중에 그 이슈를 보는 사람은 이슈 #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과 같아야 합니다.- 그리고 다음 중 하나 이상을 구현합니다:
- 가장 세밀하게 제어하려면
#nodes_visible_to_user(user, nodes). nodes_visible_to_user를 재정의하지 않는 경우 필요한#can_read_reference?.- ID로 객체를 조회하는 액티브 레코드 관계인
#references_relation. - 노드를 직접 필터링하는
#nodes_user_can_reference(user, nodes).
- 가장 세밀하게 제어하려면
참조 유형마다 이 클래스를 구현하지 않으면 Markdown 처리 중에 애플리케이션이 예외를 발생시킵니다.