大学の実験でChromiumに勝手に機能を追加してみた話

これは Chromium Browserアドベントカレンダーの15日目の記事です。
この記事では所属する電子情報工学科の実験でChromiumに「指定したキーワードを含む特定の検索履歴のみ非表示にする」という機能を勝手に実装した時の体験をつらつら書いて行きたいと思います。
学科の先輩で現在Blink-Workerチームにいらっしゃるamiq11さんが在学中にこの実験のTAをされていたこともあって声をかけていただきました。 プロの方々によるとても素敵な記事ばかりが並ぶ中で恐縮ですが、「ちょっと勉強がてらChromiumソースコードをとりあえず読んでみて、何かちょっとした機能を加えてみたい、改造したい」な ニッチな人々の参考になれば嬉しいです…!

なんで大学の実験でChromium

東大工学部電気系3年生は3〜6個のテーマの実験を行うことが必修となっており、私は今学期、他の二つの実験と併せて田浦健二郎先生による「大規模ソフトウェアを手探る」実験を履修しました。
この実験は「演習レベルの小さなプログラムが作れること」と、「実用規模のプログラムが作れること」のギャップを埋める (ための知識と経験を得る)ことを目的に、1〜数百万行のオープンソースソフトウェアをソースからビルドし新しい機能を加えたりします。
Chromiumの他にFirefoxJavaScriptエンジン、SpiderMonkeyや最近そのリッチな自動補完などで人気なfish shellを題材にした班もあり、 実際に本家にPRを出して変更がマージされたりしています。

実験で追加した機能

今回私の班が実装したのは、「Chromiumの設定画面で非表示キーワードを設定すると、Chromium上部の検索ボックスの自動補完候補からそのキーワードを含む過去の検索履歴を除く」というものです。
弊学科でスライド投影中にブラウザを起動してうっかり気まずい検索履歴を表示しちゃう方がよくいるという完全に内輪ネタから始まったので深い理由はないです。 Chrome上部の検索ボックスの正式名称は「Omnibox」といい、ユーザーの入力に従って、「過去に検索した検索クエリ」「過去に閲覧したページのURL」「Google検索で人気の検索」など様々なタイプの自動補完候補をサジェストしてくれています。

f:id:akaringo030402:20171202164136p:plain:w400

ちなみにsafariだとこんな感じ。Chromeでは様々なタイプの自動補完候補が並べられて表示されるのに対し、 safariはそれぞれの自動補完候補のタイプ別にセクションを分けているのがわかりますね。 f:id:akaringo030402:20171202164126p:plain:w400

今回はどう初心者がChromiumのソースからビルドして数百万行あるChromiumソースコードから変更したい箇所を見つけ、 機能を追加していくのかを書きたいと思います。
この実験では、実験レポートの代わりにブログ記事を提出可能というルールがあるため、 もしより詳細な内容(というか悪戦苦闘の様子)が知りたい奇特な方がいらっしゃれば下にそれぞれのステップと対応する、提出した記事の一覧を貼ったのでのぞいてみてください(゚▽゚*)

変更した内容 ブログ記事
ビルドする 大規模ソフトウェア(Chromium)を手探る 導入・ビルド編 - あさりさんの作業日記
ソースコードとドキュメントを手探る 大規模ソフトウェアを手探る Chromeのソースコードとドキュメントをひたすら漁る - あさりさんの作業日記
検索ボックスのデータの流れを追い、自動補完候補にキーワードフィルターをかける 大規模ソフトウェアを手探る 検索ボックスにおける自動補完・サジェスチョンのデータの流れを追う - あさりさんの作業日記
user profileに新しい設定項目を追加 大規模ソフトウェア(Chromium)を手探る user profileに設定を追加する - あさりさんの作業日記
設定画面に非表示キーワードを新しく追加するための新たなWebUIをつける 大規模ソフトウェア(Chromium)を手探る - 設定画面(settings)を手探る1 - - elechoのぶろぐ
入力した情報をuser profileに引き渡すためのcall backハンドラを実装する 大規模ソフトウェア(Chromium)を手探る callbackハンドラを追加する・全体の感想 - あさりさんの作業日記

ソースコードを(文字通り)手探る

実装する方法として、当初は以下の二つのアイディアを考えました。

  1. 入力に従って検索ボックスの自動補完情報を更新しているモジュール上で、特定の単語が含まれる検索履歴は自動補完候補から弾くようにする。
  2. 特定の単語が含まれる検索ワードはそもそも検索履歴DBに保存されないようにする。

そもそもデータにすら残さないのってどうなんだ?ということで、方針1で実装進めて行くことにしました。

Design Docを探してみる

Chromium Browser初日の記事で紹介いただいているように、 Chromiumには強力なコードサーチページがあります。ただ土地勘のない初心者が数百万行以上とも言われるChromiumソースコードをいきなりそれっぽいキーワードでサーチする/目grepしようとすると、 果てしない壁にぶつかります。
もし趣味で手探ってみたい!という方がいれば、まず関連するDesign Docsを検索し、自分がいじってみたいモジュールはどれか、ソースコードはどのディレクトリにありそうか、ある程度目処をつけることをオススメします(自分の教訓です)。
Chromium (Chrome Browser, Chrome OS)の開発者向けドキュメント (design doc) は公式ホームページ、The Chromium ProjectのFor Developers>Design Documentsから検索できます。
ここでOmniboxで検索をかけてみると、Omnibox: History Provider - The Chromium Projectsというドキュメントに以下のような記述があるのを確認できます。

One of the autocomplete providers for the omnibox (the HistoryQuickProvider, HQP for short) serves up autocomplete candidates from the profile's history database. As the user starts typing into the omnibox, the HQP performs a search in its index of significant historical visits for the term or terms which have been typed.

HistoryProvider自体は過去の検索履歴から入力文字列とマッチしてそうなエントリを返すので、今回の「ユーザーの過去に入力した検索文字列」をOmniboxに供給している訳ではないのですが、 上の記述から自動補完候補は様々な種類のプロバイダからユーザーの検索履歴DBから供給されていること、ユーザーの入力に応じて入力文字列とマッチした過去の履歴を補完候補としてサジェストしているのが推測できます。

またChromeのUser Experiment関連のドキュメント、Omnibox - The Chromium Projectsをみると、いわゆる「ユーザーが検索の際に実際に入力した文字列に基づくサジェスチョン」はSearch Suggestというタイプに分類されているとわかります。 SearchProviderみたいな名前がついてそう………ざっくりですがだいたい目処がついてきました。

実際にコードをちょっといじってみる

Chrome Code SearchでSearchProviderクラスがどこにあるかちょっと調べてみると、 src/components/omnibox/browser/search_provider.cc で実際にSearchProviderクラスが実際に定義されていることがわかります。

最初は単純にこのSearchProviderの中でキーワードフィルターをかければいいのでは??と安易に考え、とりあえずある文字列とサジェスチョンが一致する場合は自動補完候補から除き、実際にサジェスチョンから消えるか確認してみることにしました。

SearchProvider::ScoreHistoryResultsHelper(...) {
    SearchSuggestionParser::SuggestResults scored_results;
    if (base::EqualsASCII(history_suggestions.suggestion(), “hogehoge”)  == false){
      scored_results.insert(insertion_position, history_suggestion);
    }
    return scored_result;
}

結果 : 普通に自動補完に出てきたwwwwww

どうやら単純にProvider側でとりあえず弾く、という実装だけでは不十分だったようなので、 ちゃんと自動補完のデータがどう流れてきているのか、デバッカで追ってみることにしました。

自動補完のデータの流れを追ってみる

地道にデバックやエラーのバックトレース結果をみると、Omniboxの自動補完候補は

  1. まず下図の右手側にあるそれぞれのProviderが入力とマッチするデータをprofileなどからそれぞれ取ってくる
  2. Controllerクラスがこれらすべての自動補完候補をAutocomoleteMatchesにまとめる(この結果が図のmatches_)
  3. 関連度等に基づいてこの自動補完候補をソートする(この結果が図のautcocomplete_result_)
  4. ソートされた結果が新たな自動補完候補として更新される

f:id:akaringo030402:20171101194213p:plain
自動補完候補データの流れ

の流れで提供されていることがわかりました。

次に、一個一個プロバイダから自動補完データを渡している部分をコメントアウトする頭の悪い感じの作業をしてSearchProviderの候補から確かに除いたはずの 非表示キーワード自動補完がどこから流れてきたのか検証してみると、実はShortcutProviderというクラスから弾いたはずのデータが供給されていることがわかりました。

Providerの中でも一番下に示された意味ありげなShortcutProviderというProviderクラス、当初はあまりちゃんとマークしていなかったのですが、 このクラスのヘッダーファイルを見てみると以下の記述があります。

// Provider of recently autocompleted links. Provides autocomplete suggestions
// from previously selected suggestions. The more often a user selects a
// suggestion for a given search term the higher will be that suggestion's
// ranking for future uses of that search term.
class ShortcutsProvider : public AutocompleteProvider,
                          public ShortcutsBackend::ShortcutsBackendObserver {...}

"Provider of recently autocompleted links. Provides autocomplete suggestions from previously selected suggestions. "

ん??????

つまりこれをみると、最近サジェストされ、ユーザーが実際に検索した候補のデータを別のDBに保存し、そこからデータを供給していることがわかりました。 こうすると例えSearchProvider側であるキーワードを含むものを自動補完候補から除いても、こちらのProvider側が読み込んでいるDBにすでに 非表示キーワードを含んだ履歴が残されていると、こちらからデータが供給されてしまうことがわかります。

f:id:akaringo030402:20171101194249p:plain
AutocompleteControllerクラスでデータをソートする前にフィルターをかけるよう実装を変更

それぞれの個々のProviderにフィルターをかけるような実装でもよかったのかもしれませんが、自動補完として集められた結果に割と重複が多いことからも無駄が多いのではないか?という指摘をTAの方にいただいきました。 そこで以下のように、全ての補完候補をまとめて重複を除いた後、非表示キーワードを含むないしは非表示キーワードと一致する自動補完結果については除外するように変更を加えました。
後述するUser Profileの情報を読み込んで非表示キーワードが設定されていた場合にはSortAndCullWithKeyword()関数を呼び、このSortAndCullWithKeyword()は非表示キーワードを含むかどうかfind()で判定し、のぞいた後に SortAndCull()という候補のソートを行う関数に渡しています。

bool IsRemovableTypeFromMatch(AutocompleteMatchType::Type type) {
  return type == AutocompleteMatchType::HISTORY_TITLE ||
         type == AutocompleteMatchType::HISTORY_BODY ||
         type == AutocompleteMatchType::SEARCH_HISTORY ||
         type == AutocompleteMatchType::SEARCH_SUGGEST_TAIL;
}

}  // namespace

void AutocompleteResult::SortAndCullWithKeyword(
    const AutocompleteInput& input,
    TemplateURLService* template_url_service,
    const std::string& keyword) {
  std::vector<base::string16> restricted_keywords =
      TokenizesKeywordsStringToKeywordsVec(keyword);
  const base::string16& input_text = input.text();
  matches_.erase(
      std::remove_if(
          matches_.begin(), matches_.end(),
          [&restricted_keywords, &input_text](const AutocompleteMatch& match) {
            bool removable = IsRemovableTypeFromMatch(match.type);
            if (removable) {
              base::string16 match_text = base::ToLowerASCII(match.contents);
              for (auto& restricted_keyword : restricted_keywords) {
                // Omniboxの入力文字列と一致しないかつ表示したくないキーワードを含む検索候補を除くように書き換える。
                if (input_text != match_text &&
                    match_text.find(restricted_keyword) !=
                        base::string16::npos) {
                  return true;
                }
              }
            }
            return false;
          }),
      matches_.end());

  SortAndCull(input, template_url_service);
}

IsRemovableTypeFromMatch()は自動補完候補がユーザーの過去の検索に基づくものであるかどうか判定し、過去の検索クエリや訪問履歴に基づいた自動補完データ出会った場合のみ、 非表示キーワードを含む自動補完候補を候補から除きます。
これは「自動補完候補のうち、キーワードを含むものは全て除く」とすると普段の検索でGoogle検索で人気のキーワードに基づく自動補完候補なども消去されてしまうために、普段の利用で不便が生じそうと考えたためです。
余談ですが、kRestrictedKeywordについてはカンマ区切りにすれば複数キーワードも指定可にしており、TokenizesKeywordsStringToKeywordsVec(keyword)でtokenizeをしています。

User Profileに非表示キーワードを追加する

上の変更で指定されたキーワードを含む自動補完候補フィルタリングについては機能するようになりました。あとは非表示したいキーワードをChromeの通用の設定画面で追加できるようにしたあと、 User Profileに保存してC++側から参照できるように変更を加えます。

Preferenceに非表示キーワードを追加する

ユーザーの登録したChromiumの設定はUser Profileのpreferenceに保存され、それぞれに固有のkey(pref_name)によって識別されます。 preferecesに任意の設定について追加したい場合には、

  1. PrefNamesクラスで新しく設定したいpreferenceのnameとデフォルト値を登録する。
  2. ProfileImplクラスのRegisterProfilePrefsd()という関数でこのpreferenceを登録する。

というステップでProfileにこの新しいpreferenceの値がストアされるようになり、PrefServiceを呼ぶことで、C++のプログラムから参照できるようになります。

www.chromium.org

preferenceへの新たな項目の追加はchrome/common/pref_names.h及びchrome/common/pref_names.cppに以下のように追加します。

まずkRestrictedKeywordchrome/common/pref_names.hで宣言します。

namespace prefs {
// Profile prefs. Please add Local State prefs below instead.
...
extern const char kRestrictedKeyword[];
}  // namespace prefs

次に、cpp側でUI(JavaScript)側からこのpreferenceを参照する時に必要なpref_nameを登録します。ここに格納される値はデフォルト値ではなくてキーとして使われる識別子です。

namespace prefs {
// *************** PROFILE PREFS ***************
// These are attached to the user profile
...
const char kRestrictedKeyword[] = "RestrictedKeyword";
}  // namespace prefs

最後にProfileImplクラスのRegisterProfilePrefsd()という関数でこのpreferenceを登録します。ちなみにこれをやらないと実行時にコアダンプします。

 // RegisterProfilePrefsd()というvoid関数でpreferenceの登録が行われている
void ProfileImpl::RegisterProfilePrefs(
    user_prefs::PrefRegistrySyncable* registry) {
  registry->RegisterBooleanPref(prefs::kSavingBrowserHistoryDisabled, false);
...
  registry->RegisterStringPref(prefs::kRestrictedKeyword, std::string());
}

AutocompleteController側からPrefServiceを呼び出す

ユーザーのpreferencesを取得するためには、PrefServiceをservice clientから呼ぶ必要があります。 自動補完候補のソートを行なっているAutocompleteResultクラスは、service clientに順ずるものに直接はアクセスできないため、AutocompleteController側でkRestrictedKeywordを取得し、SortAndCullWithKeyword()関数に渡す形で実装することにしました。

void AutocompleteController::UpdateResult(
    bool regenerate_result,
    bool force_notify_default_match_changed) {
  PrefService* prefs = provider_client_->GetPrefs();
  const std::string keyword = prefs->GetString(prefs::kRestrictedKeyword);
  // 非表示キーワードが設定されていた時はSortAndCullWithKeyword()を、
  // そうでない時は通常のSortAndCull()を呼ぶ。
  if (keyword) {
    result_.SortAndCullWithKeyword(input_, template_url_service_, keyword);
  } else {
    result_.SortAndCull(input_, template_url_service_);
  }
...
}

設定画面に非表示キーワード登録欄を追加する

ここまでの変更でpreferenceの情報を取得し、登録された任意の一つもしくは複数のキーワードを含む自動補完候補のフィルタリンリング機能の実装が完了しました。
あとは実際にChromeのデフォルトの設定画面に非表示キーワードを登録できるようにUIを変更すれば完成です。

WebUIを変更する

まず設定画面のHTML, JavaScriptファイルを追加ないし変更をして、非表示キーワードを登録するセクションを追加します。
WebUIの変更については同じチームのelechoくんが以下の記事で詳細にまとめていくれているので興味がある方はelechoくんの記事も呼んでください :)

elecho.hatenablog.com elecho.hatenablog.com

ChromeのWebUIの変更については以下のドキュメントが参考になりました。

www.chromium.org

ドキュメントにもあるように、WebUIの変更にはWebUIページの作成だけでなく、リソースへの追加、ルーティングの設定等、多くのファイルの変更が必要となり、 どこか忘れると正しく表示されなかったりして、またデバックもいい方法がわからず苦労しました…
ChromeでのいいWebUIのデバッグ方法が知りたいなとちょっと思いました。

実際にChrome Settingsにキーワード登録セクションを追加しました。(画像は授業内でのデモ用に設定画面の一番上に非表示キーワード登録セクションを追加してみます)

f:id:akaringo030402:20171211104724p:plain

callbackハンドラを追加する

ドキュメントにも紹介されていますが、新たに追加したWebUIからC++モジュールの情報を参照したり、JavaScript側でのsettingsでの変更をC++のPrefServiceを用いてuser profileに追加するためには、 メッセージコールバックハンドラを追加する方法が推奨されている(みたいです)。

You probably want your new WebUI page to be able to do something or get information from the C++ world. For this, we use message callback handlers. Let's say that we don't trust the Javascript engine to be able to add two integers together (since we know that it uses floating point values internally). We could add a callback handler to perform integer arithmetic for us.

ここについては特に手探りで行なった部分も多く、誤りなどあるかもしれませんが、以下のような手順でcallbackハンドラクラスを追加しました。

  1. handlerクラス(h, cppファイル)を追加する。
  2. MessageCallbackを登録し、JavaScriptで呼ばれる関数名と対応するC++プログラムでの関数名を決める
  3. 実際に呼ばれるC++の関数の動作を定義する
  4. 追加したハンドラを新しくビルドターゲットに追加する

handlerクラスを追加する

新しくmessage callback handlerを追加する場合にはchrome/browser/ui/webui/settings/以下にsettings_restricted_keyword_pages_handler.hsettings_restricted_keyword_pages_handler.cppファイルを加え、設定画面の別のセクションのcallback handlerを参考に実装しました。
RestrictedKeywordHandlerクラスのヘッダファイルはこんな感じ。

class RestrictedKeywordHandler : public SettingsPageUIHandler,
                            public ui::TableModelObserver {
 public:
  explicit RestrictedKeywordHandler(content::WebUI* webui);
  ~RestrictedKeywordHandler() override;

  // SettingsPageUIHandler:
  void RegisterMessages() override;
  void OnJavascriptAllowed() override;
  void OnJavascriptDisallowed() override;

  // ui::TableModelObserver:
  void OnModelChanged() override;
  void OnItemsChanged(int start, int length) override;
  void OnItemsAdded(int start, int length) override;
  void OnItemsRemoved(int start, int length) override;

 private:
  PrefChangeRegistrar pref_change_registrar_;
  CustomHomePagesTableModel restricted_custom_page_table_model_;
  DISALLOW_COPY_AND_ASSIGN(RestrictedKeywordHandler);
 };

MessageCallbackを登録する

callback handlerクラスで定義されたC++の関数をMessageCallbackに登録することで、JavaScriptから指定した関数名で呼べばregisterしたC++の関数が呼ばれるようになります。
今回はprefs::kRestrictedKeywordを追加する関数setRestrictedKeyword、またユーザーが設定画面を開いた時に今の設定値を見えるようにJavaScriptに情報を渡すための関数getRestrictedKeywordを追加します。
先ほど作成したsettings_restricted_keyword_pages_handler.cppRestrictedKeywordHandler::RegisterMessages()で登録を行います。

void RestrictedKeywordHandler::RegisterMessages() {
  if (Profile::FromWebUI(web_ui())->IsOffTheRecord())
    return;
  web_ui()->RegisterMessageCallback("setRestrictedKeyword",
                 base::Bind(&RestrictedKeywordHandler::HandleSetRestrictedKeyword,
                            base::Unretained(this)));
  web_ui()->RegisterMessageCallback("getRestrictedKeyword",
                            base::Bind(&RestrictedKeywordHandler::HandleGetRestrictedKeyword,
                                       base::Unretained(this)));
}

実際に実行されるC++関数を追加する

実際にuser profileを取得して登録されたキーワードの情報を取り出したり、設定画面でキーワードが登録された際にpreferenceを変更する関数をsettings_restricted_keyword_pages_handler.cppに実装していきます。HandleAddRestrictedKeyword()ではWebUIからProfileを取得し、JavaScript側から渡されたvalue(新しく追加されたキーワード)をprefs::kRestrictedKeywordにセットします。また、 C++側でpreferenceから現在のキーワードを取得し、JavaScriptにcallbackを返すためのHandleGetRestrictedKeyword()関数も追加します。

void RestrictedKeywordHandler::HandleAddRestrictedKeyword(const base::ListValue* args) {
  std::string pref_name;
  args->GetString(0, &pref_name);
  const base::Value* value;
  args->Get(1, &value);
  PrefService* prefs = Profile::FromWebUI(web_ui())->GetPrefs();
  prefs->SetString(prefs::kRestrictedKeyword, value->GetString());
}

void RestrictedKeywordHandler::HandleGetRestrictedKeyword(const base::ListValue* args) {
  CHECK_EQ(1U, args->GetSize());
  const base::Value* callback_id;
  CHECK(args->Get(0, &callback_id));
  AllowJavascript();
  ResolveJavascriptCallback(*callback_id, base::Value(GetRestrictedKeyword()));
}

std::string RestrictedKeywordHandler::GetRestrictedKeyword() {
  std::string RestrictedKeyword;
  PrefService* prefs = Profile::FromWebUI(web_ui())->GetPrefs();
  RestrictedKeyword = prefs->GetString(prefs::kRestrictedKeyword);
  return RestrictedKeyword;
}

ビルドターゲットに追加する

新しくsourcesを追加する時はgnファイルに追加したファイルへのパスを登録をし、ビルドターゲットに追加します。
通常はchrome/browser/ui/BUILD.gnにccファイルとhファイルへのパスを追加するのみですが、 設定画面の変更については別にSettingPagesHandlerとしてchrome/browser/ui/webui/settings/md_settings_ui.ccに以下のように追加する必要がありました。
この辺りについては類似するクラスを参照しながら必要そうな変更にあたりをつけていきました。

#include "chrome/browser/ui/webui/settings/settings_restricted_keyword_pages_handler.h"

MdSettingsUI::MdSettingsUI(content::WebUI* web_ui)
    : content::WebUIController(web_ui),
      WebContentsObserver(web_ui->GetWebContents()) {
   ...
   AddSettingsPageUIHandler(base::MakeUnique<RestrictedKeywordHandler>(web_ui));
   ...
}

まとめ

短い時間でしたが、Chromiumソースコードをコードサーチやdesign docをフル活用して手探る中で、 ブラウザでのざまざまなプロセスの動きやC++世界とJavaScriptの世界でどうやりとりがなされているか、またChromiumでのWebUIの構成など 様々なことが学べました。
Chromiumに実際にコミットしなくても、「こんな機能できないかな…!!」くらいの気持ちで色々いじってみるととても勉強になるので 年末年始暇な方などは遊んでみてください〜 :)