この記事はChromium Browser Advent Calendar 2017 19日目の記事です。カレンダー参加者にはChromiumコミッタの方たちが並んでいますが、空きがあったのでせっかくならと思い飛び込んでみました。

まえがき

筆者は普段WEBアプリケーションのフロントエンド/サーバーサイドの開発をしています。Chrome内部のアーキテクチャや仕組みについてはアドベントカレンダーの他の記事に詳しく書かれているのでそちらに任せるとして(どの記事も力作なのでまだ全部読み切れていない)、普段WEBフロントエンドを書く人間にとって価値のある情報をまとめるべくこの記事を執筆しました。

Chromiumのコードベースはとても巨大で、何も知らない状態からいきなりコードを読み始めるのはなかなか難しいです。普段利用しているブラウザのAPIがどういう風に実装されているかを探そうすのもコツが要りますし、筆者のように普段HTML/CSS/JavaScriptを通してAPIを利用する側の人間にとっては、なんとなく手当たり次第にコードを眺めてもしっくりこない、というような状況でした。

ChromiumにはLayoutTestsというディレクトリがあります。ここにはHTMLやJavaScriptの仕様として定められているAPIについてのテストが数多く存在します。テストはHTMLとJavaScriptのみで書かれており、基本的にはHTMLファイルをブラウザで開くだけでテストが実行されるため、手軽にテストの結果を確認することができます(一部のテストではテスト用に特別にビルドされたJSとC++をバインディングするファイルが必要なようです)。

src/third_party/WebKitというディレクトリ下にあることから分かるように、LayoutTestsは歴史的にはWebKitのソースコードから受け継がれたコードベースのようですが、Chromium(Blink)がWebKitからフォークされたあとも継続的に追加・メンテナンスされています。機能追加やバグフィックスにあわせて既存のテストファイルにテストケースが追加されることもあるようですし、HTMLの仕様やMDNなどのドキュメントに書かれていない挙動についてもテストケースが書かれていることも多いので、仕様そのものではなくブラウザの実装にもとづいたAPIの振る舞いを把握することができます。このLayoutTestsを読んでいくだけでもフロントエンドを開発する人間にとって有益ではあるのですが、先にも書いたとおりLayoutTestsは機能開発やバグフィックスと一緒にコミットされていることも多く、LayoutTestsを入り口にしてChromiumの実装を読んでいくとコードベースの理解が捗ることに気が付きました。この記事ではLayoutTestsからいくつかテストファイルを選び、テストの内容を出発点にその機能やAPIがどのように実装されているのか、といったところを見ていきたいと思います。なお今回は筆者が個人的に興味を持っている次の3つのAPIを取り上げました。

  1. Custom Elements
  2. Web Share API
  3. Variable fonts

余談ですが、アドベントカレンダー1日目の記事としてnhiroki さんが書かれた Chromium のソースコードの歩き方は、この記事を書くにあたって非常に参考にさせてもらいました。Chromiumのコードを読み始める際にはぜひ一読をオススメします。code search神。

(※以前同じような主旨でWebKitのソースコードの読み進め方について発表をする機会がありましたが、今回はChromiumアドベントカレンダー用に記事を書くにあたって、Chromeでは実装されているけど他ブラウザでは未実装の機能/APIを中心にコードリーディングの題材を選び、実際に筆者がコードリーディングしたメモ書きのような体裁で記しました。)

1. Custom Elements

Cusomt Elementsとはウェブページの制作者が独自のDOM要素を定義し利用できるようにするための仕様です。Can I Use?を見ると、主要4ブラウザ(IE/Edge, Safari, Firefox, Chrome)のうちV0のAPIが実装されているのはChromeだけで、V1の方はChromeとSafariで部分的に実装されているようです。W3CにはWorking Draftがありますが、ここ数年間は活発な議論が続いている印象があり、最終的にどのような仕様に落ち着くのかはまだ定まっていないのが実状でしょう。Chromeではかなり前のバージョンからV0が実装されていましたし、Polymer Projectなどを見てもCustom Elements関連の技術に積極的な姿勢が窺えます。

さっそくテストファイルの方を見ていきましょう。src/third_party/WebKit/LayoutTestsの直下にcustom-elementsというディレクトリがありますね。いくつかテストファイルが存在しますが、ここではspec/define-element.htmlを見ていきます。ファイル名が示すとおり、このテストではCustom Elementを定義するAPIであるcustomElements.define()の挙動をテストしているようです。テストケースから一部を抜粋して見てみます。

test_with_window((w) => {
  // https://html.spec.whatwg.org/multipage/scripting.html#valid-custom-element-name
  let invalid_names = [
    'annotation-xml',
    'color-profile',
    'font-face',
    'font-face-src',
    'font-face-uri',
    'font-face-format',
    'font-face-name',
    'missing-glyph',
    'div', 'p',
    'nothtmlbutnohyphen',
    '-not-initial-a-z', '0not-initial-a-z', 'Not-initial-a-z',
    'intermediate-UPPERCASE-letters',
    'bad-\u00b6', 'bad-\u00b8', 'bad-\u00bf', 'bad-\u00d7', 'bad-\u00f7',
    'bad-\u037e', 'bad-\u037e', 'bad-\u2000', 'bad-\u200e', 'bad-\u203e',
    'bad-\u2041', 'bad-\u206f', 'bad-\u2190', 'bad-\u2bff', 'bad-\u2ff0',
    'bad-\u3000', 'bad-\ud800', 'bad-\uf8ff', 'bad-\ufdd0', 'bad-\ufdef',
    'bad-\ufffe', 'bad-\uffff', 'bad-' + String.fromCodePoint(0xf0000)
  ];
  class X extends w.HTMLElement {}
  invalid_names.forEach((name) => {
    assert_throws_dom_exception(w, 'SYNTAX_ERR', () => {
      w.customElements.define(name, X);
    })
  });
}, 'Invalid names');

このテストケースではCustom Elementの名前として使用できる文字列を試験しています。font-faceなどCSSプロパティとして存在する文字列や、HTML要素としてすでに存在するdivp、はたまた文字列中に大文字を含むintermediate-UPPERCASE-lettersや特定のunicode文字を含むbad-\u00b6などの文字列はCustom Elementの名前として利用できないことが分かります。この振る舞いを見るに、customElements.define()を呼び出した際に与えられた文字列が利用可能な名前かどうかを判定するバリデーションロジックがChromiumに存在するはずなので、その実装を探してみましょう。

code searchの検索窓にCustomElement valid nameと入れて検索してみると、検索結果の一番上にsrc/third_party/WebKit/Source/core/html/custom/CustomElement.hというファイルが出てきました。先に見ていたテストファイルと同じsrc/third_party/WebKitの下にありますし、その先のディレクトリもかなりそれっぽいです。ファイル名の下には検索キーワードにヒットした行が表示されますが、static bool IsValidName(const AtomicString& name) {という行がヒットしたようなので、これを見てみましょう。この部分のコードのパーマリンクはこちらです。

  static bool IsValidName(const AtomicString& name) {
    // This quickly rejects all common built-in element names.
    if (name.find('-', 1) == kNotFound)
      return false;

    if (!IsASCIILower(name[0]))
      return false;

    if (name.Is8Bit()) {
      const LChar* characters = name.Characters8();
      for (size_t i = 1; i < name.length(); ++i) {
        if (!Character::IsPotentialCustomElementName8BitChar(characters[i]))
          return false;
      }
    } else {
      const UChar* characters = name.Characters16();
      for (size_t i = 1; i < name.length();) {
        UChar32 ch;
        U16_NEXT(characters, i, name.length(), ch);
        if (!Character::IsPotentialCustomElementNameChar(ch))
          return false;
      }
    }

    return !IsHyphenatedSpecElementName(name);
  }

このコードを見ると、やはりここが与えられた文字列がCustom Elementとしてvalidな名前かどうかを判定するロジックのようです。文字列中の-を探したり、1つ1つの文字がCharacter::IsPotentialCustomElementNameCharであるかどうかをチェックしています。また関数の最後にはIsHyphenatedSpecElementName()を呼び出しています。ここをクリックして実装を見てみると(code search神!)、次のような実装であることが分かります。

bool CustomElement::IsHyphenatedSpecElementName(const AtomicString& name) {
  // Even if Blink does not implement one of the related specs, (for
  // example annotation-xml is from MathML, which Blink does not
  // implement) we must prohibit using the name because that is
  // required by the HTML spec which we *do* implement. Don't remove
  // names from this list without removing them from the HTML spec
  // first.
  DEFINE_STATIC_LOCAL(HashSet<AtomicString>, hyphenated_spec_element_names,
                      ({
                          "annotation-xml", "color-profile", "font-face",
                          "font-face-src", "font-face-uri", "font-face-format",
                          "font-face-name", "missing-glyph",
                      }));
  return hyphenated_spec_element_names.Contains(name);
}

ここを見ると、先の判定ロジック的にはvalidとみなされる文字列であっても、あらかじめ予約された名前は利用できないことが分かります。font-faceなどの名前はこのロジックによって弾かれているようですね。コメントにもある通り、Chromium自体には実装されていなくとも、仕様上利用できない文字列がある点も興味深いです。

2. Web Share API

次にWeb Share APIについて見ていきたいと思います。Web Share APIはWebページからユーザーが選んだ任意のアプリやページにテキストやリンクを共有するための仕組みについて定められた仕様です。仕様の提案自体が比較的新しく、Can I Use?を見てもAndroid ChromeでのみAPIが提供されているようで、個人的に今後の行く末が気になる注目APIの1つです。Web Share APIについてはjxckさんのブログ記事に詳しく書かれています。

こちらも先と同様にsrc/third_party/WebKit/LayoutTestsの下にwebshareというディレクトリがありますね。この中にあるshare-success.htmlというテストファイルを見てみましょう。ファイル名が示すようにShare APIの正常系の振る舞いをテストしています。テストケースの一部を抜粋して見てみます。

share_test(mock => {
  const url = 'https://www.example.com/some/path?some_query#some_fragment';
  mock.pushShareResult('the title', 'the message', getAbsoluteUrl(url),
                       blink.mojom.ShareError.OK);
  return callWithKeyDown(() => navigator.share(
        {title: 'the title', text: 'the message', url: url})).then(
        result => assert_equals(result, undefined));
}, 'successful share');

share_test(mock => {
  const url = '//www.example.com/some/path?some_query#some_fragment';
  mock.pushShareResult('', '', getAbsoluteUrl(url), blink.mojom.ShareError.OK);
  return callWithKeyDown(() => navigator.share({url: url}));
}, 'successful share with URL without a scheme');

// 中略

share_test(mock => {
  const url = 'data:foo';
  mock.pushShareResult('', '', getAbsoluteUrl(url), blink.mojom.ShareError.OK);
  return callWithKeyDown(() => navigator.share({url: url}));
}, 'successful share with a data URL');

どのテストもnavigator.share()を実行したときの振る舞いをチェックしています。navigator.share()の引数として渡しているurlの文字列に注目すると、

  • 'https://www.example.com/some/path?some_query#some_fragment'
  • '//www.example.com/some/path?some_query#some_fragment'
  • data:から始まるdataURIとしての文字列

などは利用可能なようです。これ以外の文字列がnavigator.share()の引数として適切なのかどうか、Chromium のソースコードの歩き方に書かれた内容に従って実装を探してみましょう。Web Share API使う際には他のプロセスとの通信が発生するはずです。この点を鑑みると、URLのバリデーションロジックはrendererよりはbrowser側にあるだろうと考えsrc/chrome/browserを見てみます。案の定webshareというディレクトリが存在しますね。いくつかファイルはありますが、share_service_impl.ccがAPI実装の本体のような雰囲気があります。このファイルを見てみましょう。コードはこちらです。

ファイルは200行ほどで、ShareServiceImpl::Share()API自体の実装やShareServiceImpl::OnPickerClosed()などユーザーによるUIの操作にフックされて呼ばれるコールバック関数も定義されているようです。ざっと見てみると、navigator.share()の引数として渡されるurlの文字列はShareServiceImplクラスの中ではGURL型のデータであることが分かります。GURLクラスのヘッダファイルを見てみると、ファイル冒頭にGURLクラスについて詳しくコメントが書かれています。これによると

// A parsed canonicalized URL will be guaranteed UTF-8. Only the ref (if
// specified) can be non-ASCII, the host, path, etc. will be guaranteed ASCII
// and any non-ASCII characters will be encoded and % escaped.
//
// The string representation of a URL is called the spec(). Getting the
// spec will assert if the URL is invalid to help protect against malicious
// URLs. If you want the "best effort" canonicalization of an invalid URL, you
// can use possibly_invalid_spec(). Test validity with is_valid(). Data and
// javascript URLs use GetContent() to extract the data.

とのこと。コメントに書かれている内容をもとに、URLのパース処理を少し見てみましょう。コメントの下の方にGetContent()という関数についての言及がありますね。コメント内の関数名などはcode searchの補完が効かないのでブラウザのページ内検索を使って見てみると、同じファイルの272行目にGetContent()の宣言があることが分かります。ここをクリックするとこの関数の実体や参照されている場所が一覧で見れるので、関数の実体の方に飛んでみます。

と、この先も呼び出しスタックを細かく追おうと思っていたのですが、思ったよりも長い道のりだったので少し端折ってコードの呼び出しスタック順にコードの場所だけ列挙していきます。

やっとurl_parse.ccというファイルのDoParseURL()という関数に辿り着きました。ファイルがmozillaディレクトリにあるのも面白いです。urlの文字列をパースするロジックがぎっちり詰まっていますね。LayoutTestsの方のテストケースで書かれた各URL文字列がいかにしてパースされたりvalidateされているかが分かります。このあたりのコードを追っていくことで、先に紹介したテストケース以外にもどういった文字列がurl引数として利用可能なのかを精緻に把握することができるでしょう。

3. Variable fonts

最後はVariable fontsについて見ていきましょう。Cutsom ElementsやWeb Share APIに比べると、Variable fontは聞き馴染みがない方も多いのではないでしょうか。かくいう筆者自身もこの記事を執筆するにあたって関連するLayoutTestsを見るまでその存在を知りませんでした。

Variable fontsはOpenTypeとTrueTypeのフォントに対してCSSでバリエーションを定義するための仕様です。Variable fontに対応したフォントが適用された要素に対してfont-variation-settings: "wdth" 1.1;といったCSSをあてることで、フォントの幅や高さを調節することができます。フォント関連の技術には明るくないのですが、OpenTypeのフォントは4つのaxisと呼ばれるデータを持っているそうで、それぞれのaxisがフォントのwidthやweightといったプロパティを制御しているようです。Variable fontは先のようなCSSプロパティ指定を通してこれらのaxisを動的に変更し、フォントの見た目を微調整できる機能だと言えるでしょう。例のごとくCan I Use?を見てみると、ごく最近のChromeとSafariで利用可能なようです。

ではVariable fontに関連するLayoutTestを見てみましょう。テストファイルはこちらです。短いので抜粋ではなく全体を載せます。

<!DOCTYPE html>
<meta charset="utf-8">
<script src="../../../resources/testharness.js"></script>
<script src="../../../resources/testharnessreport.js"></script>
<style>
.variation {
font-family: Skia, sans-serif;
font-size: 150px;
}

#narrow {
font-variation-settings: "wdth" 0.61998;
}

#wide {
font-variation-settings: "wdth" 1.29999;
}
</style>
<span class="variation" id="narrow">wwwwide</span><br>
<span class="variation" id="wide">wwwwide</span>
<script>
setup({ explicit_done: true });
test(function() { assert_true(narrow.getBoundingClientRect().width <
                              wide.getBoundingClientRect().width * 0.6); },
     "Narrow Skia variation is less than 60% as wide.");
done();
</script>

これまで見てきたものと異なり、CSSプロパティが関連するこのテストでは少し変わった方法で振る舞いを検証しています。テキストを内包する2つのspan要素に異なるfont-variation-settingsをあて、それぞれのgetBoundingClientRect().widthの値を比較することでVariable fontとして正しく機能しているかをチェックしているようです。font-familyで指定されているフォントがSkiaなのもChromiumならではの洒落が効いている感じがしますね。実際にこのページを開いて見てみると、Chromeでは

のように表示されましたが、Variable fontに未対応のFirefoxで開いてみると

となりました。この結果を見るとテストケースの内容も納得がいきます。

では実装を見てみましょう。とりあえず何も考えずにcode searchで"VariableFont"と検索してみます。src/third_party/WebKit/Source/platform/fonts/opentype/VariableFontCheck.hというファイルが最初にヒットしました。ヘッダファイルを見てもほとんど何もないので、同名の.cppファイルの方を見てみましょう。このファイルに定義されているのはVariableFontCheck::IsVariableFontだけのようです。Skという接頭詞がつくファイルやクラス名がいくつかありますが、どうやらこれはChromiumが利用するグラフィックエンジンのSkiaを示しているようです。IsVariableFontをクリックしてこの関数が呼ばれている箇所を見てみると、FontCustomPlatformData.cpp内の1箇所のみのようです。#if defined(OS_WIN)など各プラットフォームごとに分岐された処理が続きますが、もう少し下まで見てみると次のようなコードがありました。

Vector<SkFontArguments::Axis, 0> axes;

SkFontArguments::Axis weight_axis = {
  SkSetFourByteTag('w', 'g', 'h', 't'),
  SkFloatToScalar(selection_capabilities.weight.clampToRange(
    selection_request.weight))};
SkFontArguments::Axis width_axis = {
  SkSetFourByteTag('w', 'd', 't', 'h'),
  SkFloatToScalar(selection_capabilities.width.clampToRange(
    selection_request.width))};
SkFontArguments::Axis slant_axis = {
  SkSetFourByteTag('s', 'l', 'n', 't'),
  SkFloatToScalar(selection_capabilities.slope.clampToRange(
    selection_request.slope))};

axes.push_back(weight_axis);
axes.push_back(width_axis);
axes.push_back(slant_axis);

Variable fontの説明の中で出てきたaxisという単語や、テストファイルの中のCSSプロパティで指定した"wdth"という文字が散見されます。selection_requestというのはこの関数が引数として受け取っているFontSelectionRequest型のデータへの参照で、おそらくフォントに対するweightやwidthなどの指定 => ユーザーからのリクエストを表すデータだと思われます。また同じ引数として来ているselection_capabilitiesFontSelectionCapabilities型のデータへの参照で、こちらはフォントごとに決められたweightやwidthの上限値のようです(ココらへんは時間の都合で追いきれておらず自信ない...)。clampToRange()selection_capabilitiesselection_requestの値を加味して最終的なFontSelectionValueを返す関数のようですね。LayoutTestの方では"wdth"の値に0.61998と1.29999という2つの数値を指定していましたが、確かにこれを超えた数値を指定してみてもフォントの見た目は変わらず、Skiaフォントのwidth axisは0.61998から1.29999の範囲に制限されているようです。このあたりのファイルをもう少し見ていけば、そういったバリデーションロジックの挙動も細かく追えそうです。

まとめ

自分が興味のあるAPIや機能を選び、LayoutTestsのテストファイルを出発点としていくつか実装を見てみました。仕様やAPIドキュメントではなく実装を覗いてみることで、JavaScriptを書いているだけではなかなか気が付かないAPIの細かい仕様や振る舞いなどが垣間見えたかと思います。ブラウザのリポジトリはどれも巨大で歴史的な経緯が重なったコードも時折見られますが、純粋な仕様だけではなく現実世界の実装を見ていくことで真の振る舞いを理解することには大きな価値があると考えています。フロントエンドのコードを書く開発者や、Chromiumのソースコードを見てみたいけどどう読み始めたら良いか分からないと感じてる人にとって、この記事が少しでも助けになれば幸いです。

本記事を書くにあたってChromiumのソースコードを1次情報として利用しましたが、実装の大枠を把握することに主眼を置いたので、デバッグ用のコードを入れて動かしたりステップ実行しての動作確認などはしていません。コードの呼び出しスタックなどについての記述に誤りがある場合は、コメントでその旨指摘いただけると助かります。