株式会社クリアコード > ククログ

ククログ


OSSへのフィードバックには何をどう書けばいいのか、どのプロジェクトに報告すればいいのか

※注:この記事の対象読者は、「OSSを使用していてトラブルに遭遇しているか、改善の提案があり、その情報を開発元のイシュートラッカーに伝えようとしているが、どんな内容を書けばよいかわからない」という人です。そもそも「どこに報告すればよいかわからない」「イシュートラッカーに書き込んでいいかどうか不安がある」という方は、1つ前の記事をご参照下さい。

結城です。

1つ前の記事では、フォーラムとイシュートラッカーのどちらに書き込めばよいかわからない場合の考え方について詳しく語りました。その際、両者の目的の違いを以下のようにまとめました。

  • フォーラム:個人の問題の解決を図る場所。
  • イシュートラッカー:ソフトウェアの問題の解決、品質の向上を図る場所。

この記事ではその続編として、イシュートラッカーの目的から導かれる「適切な、書くべき報告の内容」を語ってみます。

書くべき内容、避けるべき内容

フォーラムとイシュートラッカーの目的の違いは、「投稿する内容をどうまとめればよいのか」という事を考える時の指針にもなります。「ソフトウェアの問題の解決」を目的とするイシュートラッカーに報告する内容は、その目的に沿って、「ソフトウェアの開発に関わる人達が、このソフトウェアの問題を解決するには、どのような情報が必要か?」という視点で整理する事になります。

例えば、以下の3項目は典型的な「書くべき内容」です。

  • Steps to reproduce:現象の発生条件、再現に必要な手順
    • 他の人は、どうすればその現象が起こるかを知らないし、あなたが普段どのようにその機能を使っているのかも知らないので、あなたが詳しく説明するしかありません。
    • メニューの選び方、機能の呼び出し順序など、あなたが無意識でしている操作の仕方がその現象を引き起こす決定的なきっかけとなっているかもしれません。
  • Actual result:実際に起こる結果
    • 他の人は、自分の環境で得られた結果が、あなたの見ている物と同じかどうか分からないので、あなたが詳しく説明するしかありません。
  • Expected result:本来期待される結果
    • 他の人は、あなたほどにはその機能や使い方の事を詳しく知らないかもしれないので、あなたが詳しく説明した方が効率がよいです。

その問題のせいで自分がいかに困っているか? という訴えは報告のメインの内容とはなり得ません。プロジェクト全体の中でその問題の重要度の高さを認識してもらうための材料として、問題の影響度・深刻さの説明としてそれらの訴えを盛り込む場合はありますが、そのような訴え自体が問題の原因究明や修正箇所の特定に役立つという事は、基本的には無いからです。

重複する報告も、場違いな報告も、無駄じゃないのでしていい

しかし、いざ報告しようとしても、

  • 既に他の人が報告しているにもかかわらず、自分の探し方が下手なだけで、既存の報告を見つけられていないだけかもしれない。
  • この問題をこのプロジェクトに報告するのが適切かどうか分からない。もっと適したプロジェクトがあるかもしれない。場違いな報告をしたら迷惑かもしれない。

という心配から報告をためらう、という事例はワークショップの中でもよくあります。筆者はそのような場合には、「不安を感じるくらいなら、自分が思う内容・表現のままでそのプロジェクトに報告してしまってよい」とアドバイスする事が多いです。

既知の報告がないか全く調べていない、というのはさすがにまずいですが、自分の思いつく表現で検索して報告が見つからないなら、その表現で報告して大抵は問題ありません。なぜなら、仮にその後既知の他の報告と重複していると分かって、そちらに誘導された上でクローズされたとしても、その報告内容自体が、同じ探し方をする人のための今後の誘導になるからです。

適切な報告先プロジェクトかどうか分からない、という場合も同様です。その問題が根本的には別のソフトウェアの問題だったと後から分かっても、同様の現象に遭遇した人のための誘導になるので、報告は無駄にはなりません。例えば1つ前の記事で紹介した事例では、今後同様の現象に見舞われて検索して辿り着いた人は、今後はMicrosoft IMEの動向に注意するとよい、という情報を得られる事になります。

また、根本的な原因については既に報告があったとしても、その報告で触れられている以外の現象の情報があると、それは問題の影響度合いを示す情報になります。当初の報告では些細な問題だから後回しにしようと判断されていた物が、実はユーザーレベルで大きな影響を受ける物だったと分かって、対応の重要度・緊急度が引き上げられるという事は度々あります。

却下された報告や提案にも価値がある

明らかな不具合というよりは「こうなっているとより良い」という提案の性質が強い報告の場合に多いのですが、「それは対応しない」「やらない」と判断され却下される場合もあります。そのような場合も、「無駄だった」「失敗した」という考え方をする必要はありません。というのも、「ある提案が却下された」という事そのものにも意義があるからです。

OSSのプロジェクトのスコープ*1は、必ずしもすべて明確化されているとは限りません。ある人にとっては「当然あるべき」と思える機能が無かったり、逆に、「無駄にしか思えない」という機能があったり、特に、それについて何の説明も無いという事はざらにあります。大規模なプロジェクトでは、すでに参加している人の間ですら解釈が別れていたりもします。

しかし、例えスコープが明文化されていなくても、一貫性を持った数々の判断の結果、その輪郭は段々と浮かび上がってきます。新たな判断の事例が1つ増えるという事は、不明瞭だった基準がより明確化されたという事になり、後から来た人にとって有用な情報となります。また、判断と共にその根拠が示されていれば、後になって状況が変わり根拠が薄れた時などに、それを理由として再考を求める事もできます。

そのような意義ある判断を引き出すためにも、議論は「自分の意見を押し通すため」ではなく「プロジェクトの目的をより良く達成するため」という点を念頭に置いて、「相手(敵) v.s. 自分」ではなく「問題 v.s. プロジェクト」という方向で行いましょう。反論も、主張の材料を補ったり、説明しきれていなかった理由を追加で説明したりと、意味のある反論をする事が肝要です。感情的に同じフレーズを繰り返すだけや、明確な根拠無しに強弁するだけのような不誠実な態度、他の参加者の共感を得られないゴリ押しは、百害あって一利無しです。

ただし、実際に手を動かす人が足りないから労力的に対応できないという理由で却下される場合は、より深く関わるチャンスです。自分で実装してパッチ(プルリクエスト)を提出し、その後もメンテナンスを引き受け続ければ、あなたも立派に開発者の仲間入りという訳です。

「対案」無しでも報告・提案していい

  • 報告だけでは無責任なのではないか? 対案まで考えてから出さないと駄目なんじゃないか?
  • パッチ(プルリクエスト)まで書いて初めてOSS開発への参加と言えるんじゃないか?

といった点で悩んで報告をためらう、というケースもあります。筆者はこのような場合、「対案は無くてもいいから、まず報告してみよう」とアドバイスします。

もちろん、コードのレベルで修正の仕方まではっきり分かっているのなら、パッチやプルリクエストの形でフィードバックできるに越した事はないです。しかし、そうできないならフィードバックしない方がよい、という事はありません。

むしろ、自分の考えつく解決策が最良であるとは必ずしも限りません。詳しい事情が分かっていない段階で自分の思い込みだけで独りよがりに実装を作り込んでしまうと、その労力がまるっきり無駄になるという事もあり得ます。例えば、ある機能の不具合を見つけて、その問題を解消するための複雑な変更を持ち込んだが、単にその機能全体を既存の別ライブラリで置き換えれば済む事だった、というような事はよくあります。筆者も、過去に強引な実装のパッチを代理で投稿してもらった事がありましたが、実装の筋が悪すぎたためか、それ以上話が進む事はありませんでした。

イシュートラッカーは「問題の解決」に取り組むための場であって、「特定の解決策の実現」に取り組む場ではありません。解決策は関わる人みんなで考えてよく、一人で抱え込む必要はないのです。よく「文句を言うなら対案を示すべき」という言い方がされますが、だからといって「対案が無いなら文句を言ってはならない」は真ではありません。問題解決に取り組む1つのチームの一員として、対案を考える事も含めて皆で取り組む、という姿勢・考え方をするのがポイントです。

同じ問題に取り組む仲間と思って接しよう

真摯に議論しようという話とも共通しますが、「問題を解決する開発者達 v.s. 外部から善意で協力しているただのユーザーの自分」という壁を自ら作る事は避けた方がよいです。対等な関係ではないという事を過剰に意識してしまうと、実際以上に大きな上下関係を自分の中に作り上げてしまい、コミュニケーションの妨げになります。

1つ前の記事ではOSSに新たに関わる人を新入社員に例えましたが、そう考えれば、なぜ自分とプロジェクトの間に過度に壁を作るべきでないのかが分かるのではないでしょうか。新メンバーがお客様気分で上げ膳据え膳に期待してふんぞり返るのはおかしいですし、かといってへりくだりすぎるのもおかしいですよね。

無論、雇用されフルタイムで開発に従事している人と、外部から余力の範囲で関わっている人の間では、立場も権限も異なります。しかし、そのプロジェクトの目的、あるいは報告した1つの問題の解決に向けて一緒に取り組む仲間という点では差は無いはずですから、お互いに敬意を持って接する事が大事です。

特に、「自分は問題で損をした被害者なんだ。その被害者自身がわざわざ協力してやってるんだ。だからお膳立てはあいつらの方で整えてくれて然るべき」というような捉え方は、問題の解決を遠のけさせこそすれ、近づけさせは決してしません。このような意識を強く持ってしまうと、相手の無理解を責めたり相手をやり込めたりする事にばかり意識が向いてしまい、ひいては特定個人に対する罵倒や中傷にまでエスカレートし得ます*2

まとめ

以上、OSSにフィードバックする事に不慣れな場合に悩みがち・判断に迷いがちと思われる点について、イシュートラッカーの目的から導かれる「適切な考え方」を、筆者の感覚から語ってみました。

表現を変えながら繰り返し何度か述べていますが、OSS開発に関わる際は基本的に「問題 v.s. それに立ち向かうプロジェクト参加者達(そして、その一員としての新人の自分)」という構図で考え取り組む事が大事だというのが、筆者の考えです。かつては思ったように成果を出せなかった自分も、そのように考えられるようになって以降は、より有用な報告ができ、提出したパッチやプルリクエストを取り込んでもらえる機会も増えたように思います。まだOSSの開発への参加に不慣れで、報告がうまくいかなくて悩んでいるという方は、この記事で述べた点を意識してみてはいかがでしょうか?

また、この記事で述べた事は実際にはOSSに特有の話ではなく、一般的に企業内でバグ票をやり取りするような場合にも共通して言える事です。1つ前の記事ではOSSのイシュートラッカーを出入り自由の社屋や工場に例えましたが、OSS開発の場で広く共有されているノウハウの中には、普通に仕事の上で有用な物も多く含まれています。特に、OSS開発では「多くの人を雇って人海戦術で乗り切る」というような力業での解決が難しいため、いかに少ないコストで最大の効用を得るかという点で工夫がなされている事も多いです。当社のOSS開発支援サービスでは、OSS開発に参加していきたいという企業さまのお手伝いだけでなく、OSSコミュニティでの開発ノウハウを企業内での開発に活かすお手伝いも承っています。そういった事に関心をお持ちの担当者さまがいらっしゃいましたら、ぜひお問い合わせフォームよりご連絡下さい。

*1 プロジェクトとして取り組む事の範囲。

*2 お恥ずかしい話ですが、筆者も何度かこれで失敗しています。

2019-06-19

OSSへのフィードバックはユーザーフォーラムとイシュートラッカーのどちらに書くべきか?

※注:この記事の対象読者は、「OSSを使用していてトラブルに遭遇しているか、改善の提案があり、その情報を開発元に伝えたいが、どこで伝えればよいかわからない」という人です。「どういう体裁で報告すればよいか分からない」「何を報告すればよいか分からない」という人向けの話はまた日を改めて書くつもりです。

結城です。

OSS Gateワークショップで、初めてフィードバックをしようとしているビギナー参加者のサポートをしていると、ビギナー参加者から以下のような質問を受ける事があります。

  • こんな簡単な・くだらないレベルの事を報告してもいいんでしょうか?
  • ユーザーフォーラムとイシュートラッカー(バグトラッキングシステム)*1のどちらに報告すればいいんでしょうか?
  • どのプロジェクト(開発元)に報告すればいいんでしょうか?

これらの点に対する筆者の回答は、端的には以下のようになります。

  • 簡単でも些細でも何でも、あらゆる未解決の問題は報告していい。
  • これは自分個人の問題ではなく、そのソフトウェアの全ユーザー向けに解決されるべき問題だ、と思えるならイシュートラッカーに報告していい。
  • 自分で確かめられる範囲ではこのプロダクトの上でしか問題に遭遇しない、他のプロダクトでは問題が再現しない、と言えるならとりあえずその開発元に報告していい。

奇しくも最近、これらの事を考える上で非常に興味深い事例に出会いました。以下、それを題材として上記の点をどのように判断しているのかを語ってみます。

報告者からフォーラムへ、フォーラムからイシュートラッカーへ、MozillaからMicrosoftへ

当社の法人向けのThunderbirdサポートにおいて、数ヶ月前から複数のお客さまより「メール作成時に日本語入力で漢字への変換ができなくなる」という現象のお問い合わせを頂くという事がありました。

お問い合わせの中にはMozillaZineフォーラムに同様の事例の報告がある旨をお書き添え頂いた物もあり、参照してみると、スレッドの大まかな流れは以下のようになっていました。

  1. このような現象が起こっている、既知の解決策はどれも有効でなかった、という情報が投稿される。(2018年12月末)
  2. 現象の再現条件の特定が複数人によって進められる。(2018年12月末~1月頭)
  3. Thunderbirdに対して行われた変更のうち、どの変更以降からこの現象が起こるようになったのかが特定される。(2019年1月)
  4. 暫定的な回避作として、このアドオンをインストールすると現象が起こらなくなる、という情報が寄せられる。(2019年1月末~2月)

当社の検証環境でも、条件が整うと確かに現象が再現するという事を確認できました。そこで、Mozillaが製品開発のために運用しているイシュートラッカーであるBugzillaに何か情報が無いかと検索してみたのですが、思いつく限りのキーワードで検索しても該当するBugを見つけられませんでした。そのため、ひとまず筆者自身で新しいBugとして報告を行いました。これが2019年5月の事です。

もし既に報告されている問題であれば、そちらへの参照情報が追加され、この新しいBugは「重複する報告」としてクローズされます。しかし実際にはこれが最初の報告だったようで、最終的にはFirefoxとThunderbirdの共通基盤であるGeckoエンジンの日本語入力部分の担当であるMozillaの中野さんにまで連絡が行くという事にまでなりました*2

ただ、話はそれで終わりませんでした。仮にこれがGeckoの明白なミスに起因する現象だったのであれば、Gecko側で修正されてBugはクローズされます。しかし実際には、Mozillaの中野さんによる詳細な調査ではGecko側に問題は見当たらなかったため、Microsoft IME(MS-IME)側の不具合である可能性があるとして、中野さんからMicrosoftのフィードバック窓口へ2019年5月末に報告されたそうです。

以上が、この件の2019年5月末時点の状況です。

ずっと以前から現象が確認されていても全く進展が無かったのが、イシュートラッカーに報告された途端に事態が動き出す、という事は度々あります。上記の経緯のうち、フォーラムからBugzillaへの報告までの流れはまさにそのような推移に見えます。

しかし最終的な結果を見ると、Thunderbirdの問題でなくMicrosoft IMEの問題である可能性があるなら、MozillaのイシュートラッカーであるBugzillaではなくMicrosoftに報告するのが正しかったのではないか? という見方もできます。もし仮に本当にMicrosoft IMEの問題だったのであれば、現象の確認から適切な場へ情報が伝わるまでに半年もの時間がかかってしまったという事になります。

さて、この問題は一体どのように取り扱われるべきだったのでしょうか?

誰の問題か、何をしたいかという観点で持ち込み先は変わる

ここまでの経緯には多数の論点が入り交じっていますが、目的・動機という点では大まかに以下の2つにまとめられます。

  • 報告者にとっての「どうすれば自分が今困っているこの状況を脱せるのか?」という視点=個人の問題を解決したいという視点
  • 開発者にとっての「そのソフトウェアにどんな問題があるのか、解決されるべき問題はどんな内容なのか」という視点=ソフトウェアの問題を解決したいという視点

ソフトウェアの問題が解決された結果として個人の問題が解決されるという事も、個人の問題を解決するためにした事が結果的にソフトウェアの改善にもなるという事もあります。しかし、それらはあくまで偶然の結果そうなっただけで、最初から偶然に期待するというのは効率が悪くお薦めできません。

OSSでは、というよりもこれは人間社会で一般的に言える事ですが、報告・投稿を持ち込む先は目的によって決めるものです。問題がいつまでも解決されない場合、最大の原因は問題を持ち込んだ先が適切でなかったからだった、という事がよくあります。

フォーラムが解決できる問題

上記の「フォーラムに報告がなされた例」では、関わった人達の期待とその結果は以下のように言えます。

  • トラブルに遭遇した本人(フォーラムに最初に投稿した人)にとって:
    • 期待:
      • 自分が遭遇した問題の解決。
    • 結果:
      • フォーラムに集う人達に調べて貰えて、発生条件を絞り込めた。
      • その人固有の状況で有効な暫定的回避策が判明し、当座をしのげるようになった。
  • フォーラムに集う人達にとって:
    • 期待:
      • 相談者によって道混まれた問題の解決の手助け。
    • 結果:
      • 助けを求めてやってきた人の問題の解決に力を貸せた。
  • その後同様の問題に躓いた人達にとって:
    • 期待:
      • 問題にそもそも遭遇しない事
    • 結果:
      • ソフトウェアの問題は未解決のままだったので、同じ現象に後から遭遇してしまった
      • フォーラムの投稿者と状況が一致していれば、暫定的解決策で現象を回避できる。
      • フォーラムの投稿者と状況が一致しないと、暫定的解決策では現象を回避できない
  • ソフトウェアの開発・品質向上に関わる人達にとって:
    • 期待:
      • ソフトウェアの開発が進む事、品質が向上する事。
    • 結果:
      • 普段見ていない場所で話が進行していたので、問題の存在自体を把握できなかった
      • 問題が把握されなかったため、対応もなされなかった

このように、個々のトラブルの解決や、同様のトラブルに遭遇した人の解決のための情報源にはなるものの、同様のトラブルが繰り返される事そのものの解決にまではならないのが、ユーザーフォーラムの性質と言えます。

また、ユーザーフォーラムは「ユーザー同士の相互扶助の場所」であるという点にも注意が必要です。基本的にはボランティア運営なので、分かる人がいなければ反応を得られないという事もあります。そこに集まる人は全く無関係の人よりは詳しい人である可能性が高いですが、開発者ほどには詳しくない場合も多いです*3

イシュートラッカーが解決できる問題

では仮に、最初から問題をイシュートラッカーに投稿しようとしていたら何が起こったでしょうか。筆者の見立てでは、最悪の場合の結果は以下のようになったのではないかと考えています。

  • トラブルに遭遇した本人や、同様の問題に躓いた人達にとって:
    • 問題の原因調査の方法に詳しくないと、有用な情報を提供できない。
    • そもそも技術的な知識の有無以前に、英語が不得意だと、自分の置かれた状況(問題の発生条件)を上手く説明できない。
    • 原因が特定され、次のバージョンで修正されても、今使っているバージョンでの問題は解決しない
    • 調査の過程で暫定的な解決策がたまたま見つからなければ、今この瞬間のトラブルは解決しない
  • ソフトウェアの開発・品質向上に関わる人達にとって:
    • 調査方法を知らない・開発者レベルの知識が無い人に調査の仕方を説明するために、時間と手間を使う必要がある。
    • 有効な情報を得られないと、スッキリしないものを抱えたままになる。

一方、もし理想的に推移したとすると、以下のようになるでしょう。

  • トラブルに遭遇した本人や、同様の問題に躓いた人達にとって:
    • 原因が特定され、次のバージョンで修正される事になれば、そのバージョンのリリースを待てば問題は解決する。
    • しかし、今使っているバージョンでは問題は解決しない
    • 調査の過程で暫定的な解決策がたまたま見つからなければ、今この瞬間のトラブルは解決しない
  • ソフトウェアの開発・品質向上に関わる人達にとって:
    • 問題の原因を特定できれば、根本的解決や回避策を検討できる。
    • その成果を次のバージョンに盛り込めれば、同様のトラブルの発生を防げる。
    • 根本的な原因がそのソフトウェアに無いとしても、より適切な場所へ、開発者レベルでの詳細な調査結果を報告できる。

いずれの場合も、今この瞬間に起こっている問題の解決には必ずしもつながらないし、また、その優先順位も低い、という事には注意が必要です。

イシュートラッカーに関わる人には、ある程度の知識が要求されます。報告者に全く知識が無いという場合、問題自体の深刻度がよほど高いと判断されれば、開発者もなんとか原因究明に必要な情報を報告者から引き出そうとして手厚く手助けする事もあります。しかし、それだけの手間をかけるのは割に合わないとなれば、残念ながらそれ以上の手助けはなされません。

このような事になるのは、OSSプロジェクトのイシュートラッカーは「個人の問題を解決する場所」ではなく、あくまで「ソフトウェアの開発を進め品質を向上する事を目的とする場所」だからです。

ソフトウェアの改善・ソフトウェアの問題の解決に関わりたいならイシュートラッカーへ

例えて言えば、これは企業の社屋や工場に誰でも出入り自由になっている状況のようなものです。社会科見学では見学しかできないのに対し、見学ルートを外れて「中の人達」同士の議論に混ざって発言できる、参加の機会が開かれているのがOSSです。そう考えれば、そこで行われている議論や開発に誰でも気軽についていける訳ではなく、機会があるかどうかとその中で一人前の参加者として振る舞えるかどうかは別の話だ、という事も納得しやすいでしょう。

しかし、これは言い換えれば、開発に関わりたい・品質向上に寄与したいという積極的な意志を持つ人にとっての門戸が開かれているという事です。開発の拠点が海外でも、普段の仕事がソフトウェア開発と無関係でも*4、あるいはライバル製品の開発元に所属していてすらも*5、やる気次第で自分から参入できます。

  • 「ここの所に穴が開いてるみたいなんだけど、大丈夫かなあ……塞がれないのかなあ……? 気になる……」「この機能のこの部分、これだと不十分に見えるんだけどなあ……ソワソワする……」という風に、ソフトウェアのちょっとした不具合が気になって仕方ない人。
  • 「ああもう! この人達(開発者)はまるで分かってない! 俺の方がずっとスマートにこの問題を解決できるのに!」「ああもう! なんでここの所はこんな風にできてるんだ? 使いにくい! 俺ならこんな作り方にしないのに!」という風に、問題が放置されている事に我慢がならない人*6

こういったモヤモヤを感じた時に、「でも、手を出せないんだからしょうがない」と諦めないで根本的解決できる可能性が開かれているのがOSSなのです。

ですから、「OSS開発に関わってみたい、ソフトウェアの改善に関わりたい、その最初の一歩を踏み出してみたい」と思ってOSS Gateワークショップに参加を考えた人であれば、もうその時点で「イシュートラッカーに報告する」という選択をして全く問題ない訳です。

また、その際には最初から理想的に振る舞える必要はありません。OSSプロジェクトに初めて関わる人というのは会社の新入社員と同じですから、当然要領は良くありませんし、最初は簡単な仕事からしかこなせませんし、あるいはミスもします。分からない事があれば、会社の先輩に質問するのと同じように、先に参加していた人を捕まえて質問するだけの事です。

「フォーラムに書くべきか、イシュートラッカーに書くべきか」「こんな些細な問題を報告してもいいのか」という問いへの筆者の答えが「個人の問題でないならイシュートラッカーに書いてよい」「些細でも報告してよい」となるのは、以上のような理由からです。

まとめ

以上、OSS Gateワークショップの中で度々聞かれる「フォーラムとイシュートラッカーのどちらに書くべきか判断できない」という悩みについて、フォーラムとイシュートラッカーの目的を把握した上で、「ソフトウェアの問題を解決する場所」というイシュートラッカーの目的に沿った報告をすればよい、という事を語ってみました。

イシュートラッカーへの報告は、問題が解決されればより多くの人に恩恵がもたらされます*7。OSSのイシュートラッカーはソフトウェア開発の現場ですので、ソフトウェア開発の知識がある人はよりよい報告ができる可能性がありますし、パッチやプルリクエストを通じて直接的に問題解決を図る事もできます。一歩進んだ関わり方として、皆さんもイシュートラッカーへの報告にぜひ挑戦してみて下さい。

ところで、先の事例にはもう1つ気になる点が残っています。それは、Microsoft IMEの問題だった可能性があるのにThunderbirdの問題として報告してしまってよかったのか、自分では適切な判断ができない時は一体どこに報告すればよいのか、という点です。次の記事では、それも含めて「どんな報告の仕方をすればいいか、報告の内容はどう書けばよいか」について語ってみます。

なお、当社ではOSS開発を推進したい企業さまのお手伝いをするOSS開発支援サービスを行っています(直近のアカツキさまでの事例の紹介)。社内のITエンジニアのOSS開発者としての活躍を促進したいとお考えの担当者さまがいらっしゃいましたら、ぜひお問い合わせフォームよりご連絡下さい。

*1 「ソフトウェアの不具合」を「バグ」と呼ぶことから、バグの修正状況の追跡をする物として「バグトラッキングシステム(Bug Tracking System、BTS)」という呼び方が以前はよく使われていました。しかし、この呼び方だと「不具合ではないただの要望はバグとして取り扱うべきなのかどうなのか?」という点で混乱が生じる場合があります。Mozillaが運用するバグトラッキングシステム「Bugzilla」では、不具合報告だけでなく要望もすべてひっくるめて「バグ」と呼ぶという運用ルールにする事でこの混乱を回避していますが、運用ルールを知らない人にとっては違和感が残ります。そのため現在は一般的には、バグや要望などを「解決されるべき課題=issue」と捉えて「イシュートラッキングシステム(Issue Tracking System、ITS)」や「イシュートラッカー」と呼んだり、あるいは、どんな課題であってもシステム上で管理される時はひとつの「チケット」になるからという事で「チケットトラッキングシステム」と呼んだりします。本稿では、ひとまず「イシュートラッカー」で統一しています。

*2 またその過程で、`intl.tsf.enable`という設定の値を`false`に変更すると現象が解消されるという、別の暫定的解決策も紹介されていました。これはWindows XP以降で標準となっている文字入力の仕組みを使うかどうかを制御する設定で、`false`に設定すると従来の文字入力の仕組みを使うようになります。音声からの文字入力などTSFで初めて可能になった事はできなくなりますが、その状態でも、キーボードからの一般的な文字入力操作に関しては特に支障なく行えます。

*3 これに対し、当社の法人向け有償サポートサービスは、ソースコードレベルの調査を実施したり、一定の期限内で回答したり、という事をお約束する事をもって対価を頂いているという事になります。

*4 ソフトウェア開発ではなく運用に従事しているという人や、まだ就職していなくて学生で開発に参加する人もいます。

*5 実際に、Mozillaに雇用されている人達もChromiumにバグ報告をしています。

*6 「プログラマーの三大美徳」の1つの「傲慢」とは、このような事(自分にはこの問題を解決できるはず、という考え方をすること)を指します。

*7 当社の法人向けサポートサービスは、お客様の問題(個別の問題)について原因や回避方法を調査してその解決を図ると同時に、その問題がソフトウェアの問題であった場合はソフトウェアの品質向上のためアップストリームへのフィードバックも行う事を心がけています。

2019-06-18

Thunderbirdアドオン「CardBook(連絡先)」でローカルに保存されるデータの暗号化に対応しました

CardBookと企業利用

皆さんはCardDAVという仕様をご存じでしょうか? CardDAVはWebDAVのプロトコルを使ってLDIF形式のアドレス帳をやり取りするという物で、これを用いると「読み書き両方を行えて、内容が複数PC間で同期される」という種類のリモートアドレス帳を汎用の物として実現することができます。CardDAVサーバーとして振る舞える製品にはownCloudやDAViCalなどがあり、読み取り専用に設定したリモートアドレス帳を複数人で共有するという事もできますので、企業利用では重宝する場面がありそうです。

このように便利なCardDAVですが、残念ながらThunderbirdは本体の機能としては対応していません。CardDAVベースのリモートアドレス帳を使うにはアドオンをインストールする必要があります。CardBookは、そのようなCardDAV対応のためのアドオンの一例です。

ところで、企業によっては個人情報の取り扱い方について、「顧客や取引先の個人情報をローカルに保存する際は必ず暗号化する」といったプライバシーポリシーを定めている場合があります。前出のCardBookはリモートアドレス帳のデータをIndexedDBを使用してローカルにキャッシュする設計で、この時のデータは暗号化されないため、そのままでは前述のポリシーに抵触するので採用できないという事になります。

そのような背景から、「CardBookでローカルに保存されるデータを暗号化したい」というご相談を頂き、成果を開発元に還元する前提で先行して作業を進めていたのですが、残念ながら受注には至らず、手元には実現可能性の調査のために行った試験的な実装が残るという結果になりました。しかしせっかく実装した物をそのまま放置しておくのも勿体なかったので、CardBookプロジェクトに還元したところ、標準機能の1つとして取り込まれるに至りました。現在リリース済みのCardBook 33.9以降のバージョンでは、設定画面でチェックボックスをONにすればローカルデータの暗号化が有効になるようになっています。

以下、CardBookのローカルデータベースの暗号化を実現するにあたって行った具体的な内容をご紹介します。

IndexedDBに格納するデータの暗号化と復号

幸い、Thunderbirdの基盤であるGeckoには、暗号化のための汎用APIであるWeb Crypto APIが実装されています。あるのなら使わない理由はありませんので、CardBookでもデータの暗号化はWeb Crypto APIによる共通鍵暗号で行う事にしました。何故公開鍵暗号ではなく共通鍵暗号なのかについては別項で詳しく述べていますので、そちらも併せてご覧下さい。

実装は、まず暗号化・復号を行う専用のモジュールを追加した上で、IndexedDBの読み書きを行うモジュールの書き込み用のデータを用意する箇所に暗号化処理を読み込んだデータを検証する箇所に復号処理を仕掛けることで、他のモジュールに影響を与えず透過的に動作するような組み込み方としました。

この時気をつけなくてはならないポイントとして、暗号化をどのタイミングで行うかという点が挙げられます。以下、暗号化を行っている実際の箇所を抜粋しながら説明します。

元々の設計では、IndexedDBへのデータ書き込みは以下の要領で行われていました。

addCard: unction (aDirPrefName, aCard, aMode) {
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  var storedCard = aCard;
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

ここに暗号化処理を組み込むのですが、Web Crypto APIは暗号化したデータがPromiseで返されるため、値を使うには.then()のコールバック関数で受け取るか、awaitで値の解決を待つ必要があります。コールバック関数を使うスタイルで実装するにはこのメソッドの書き方を大幅に変えなくてはなりませんが、asyncキーワードとawaitを使うと、この同期処理の関数を容易に非同期処理に対応させることができます。

addCard: async function (aDirPrefName, aCard, aMode) { // asyncキーワードを追加
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  // 暗号化処理を追加
  var storedCard = cardbookIndexedDB.encryptionEnabled ? (await cardbookEncryptor.encryptCard(aCard)) : aCard;
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

当初はこの例のように、書き込みを行う直前で暗号化を行うようにしていました。しかし実際に動作させてみると、これではIndexedDBでのデータ書き込みに失敗するという結果になりました。何故でしょうか?

実は、IndexedDBでのデータ書き込みはトランザクション開始から書き込みまでを同期的に(同じイベントループ内で)行う必要があります。この例ではトランザクション開始後にawaitを使ってしまっているせいで、store.put(storedCard)が次のイベントループでの実行となってしまい、そのせいで書き込みに失敗してしまうという訳です。

そのため最終的な実装では、以下の例のようにトランザクション開始前に暗号化を終えておくようにしました。

addCard: async function (aDirPrefName, aCard, aMode) {
  // 暗号化
  var storedCard = cardbookIndexedDB.encryptionEnabled ? (await cardbookEncryptor.encryptCard(aCard)) : aCard;
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

これなら、トランザクション開始から書き込みまでが同期的に行われるため問題ありません。

復号時には、特にこのような注意は必要ありません。また、元々IndexedDBからのデータ読み取りは結果が非同期で返されるので、CardBookのデータ読み込み処理もその前提で設計されていました。そのため、IndexedDBから返ってきたデータを非同期で復号した上で返却するという処理を挟み込んでも、CardBookのデータ読み込み処理全体としてはインターフェースを変えずに済んだのでした。

パスワード入力を求める方式にしなかった理由

CardBookのローカルデータ暗号化では、暗号化・復号に使う共通鍵は、バックグラウンドで自動生成した物を暗黙的に使い、鍵自体をユーザープロファイル内に保存する形としました。

「暗号化されたデータと鍵を同じ場所に置いておくのでは、暗号化の意味が無いじゃないか」と思うでしょうか? 実際、変更をフィードバックした際にもCardBookプロジェクトの開発者の方からも「パスワード入力を求める方式にした方がいいのではないか?」という質問がありました。Web Crypto APIの機能を使うとユーザーが入力したパスワードから秘密鍵を作る事もできる(Web Crypto APIの解説記事の「パスワードを鍵に変換する」の項をご参照下さい)のに、そうしなかったのは何故でしょうか。

ここで一旦、パスワードの安全な運用という事を考えてみましょう。パスワードを自分で記憶しておきその都度入力するという方式は、一見すると安全なように思えます。しかしながら、運用の仕方によっては却って危険になる場合があります。

  • パスワード入力には、肩越しに入力の様子を覗き見るショルダーハックや、キーの入力を監視するキーロガーなどによってパスワードを盗み取られるリスクがあります。パスワード入力の頻度が多ければ多いほど、このリスクは高まります。
  • 人間の記憶容量には限りがあるため、あまり複雑なパスワードを複数覚えるという事はできません。そのために「同じパスワードを何度も使い回す」「推測が容易なパスワードにする」といった事が行われてしまい、そうなると却って危険な状態となります。
  • 定期的なパスワード変更にも、同様の問題があります

これらの理由から、パスワードの入力は「複雑で憶えにくいパスワードを1つだけ覚える」「それをマスターパスワードとして使い、それ以外はパスワードマネージャに憶えさせる」という運用にするのが比較的安全だというのが現在の定説となっています*1

「企業でThunderbirdを使う」というシチュエーションでは、「PCのログオン」「受信メールサーバーの認証」「送信メールサーバーの認証」などでそれぞれパスワードの入力が発生する可能性があります。という事は、ここにさらに「ローカルデータの復号」のためのパスワードが加わるというのは、さすがに実運用を妨げるレベルの煩わしさでしょう。かといって、他の部分ではパスワードを使用していないのにここでだけパスワードの入力を求める、というアンバランスな運用も考えにくいです。そういった事を考慮した結果として、CardBookのローカルデータ暗号化は現在比較的安全とされている運用を想定し、

  • 秘密鍵は、自動生成した物をJWK形式でエクスポートし、「長いパスワード文字列」の一種としてThunderbirdのパスワードマネージャに記憶させる。
  • 秘密鍵の保護が必要な場合は、使用者が任意でThunderbird本体のマスターパスワード機能を有効化する。

というポリシーを採用する事にしたのでした。

まとめ

Thunderbird用アドオンのCardBookに対して行った、ローカルデータの暗号化対応の概要をご紹介しました。

当社では、一般に公開されているFirefox用アドオン・Thunderbird用アドオンをはじめとした様々なフリーソフトウェア・OSSについて機能追加・改造のご依頼を承っております。また、成果をアップストリームに還元しても差し支えがないケースでは、積極的に還元を行うようにしています。自社でフリーソフトウェア・OSSを採用したいが少しだけ要件に合わない、という事でお悩みの場合には、メールフォームからお問い合わせ下さい。

*1 ただし、これはあくまで現時点での話です。技術の進歩や、この分野での研究が進む事などによって、「最もマシ」なやり方は変わっていく可能性があります。

2019-05-28

回帰テストの対象は適切に設定しよう

先日、Bug 1541748 - New tab and restored tab notified via tabs.onCreated can have invalid (too large) index という報告をFirefoxのバグトラッキングシステムに行った結果、提出したパッチがFirefox 68に反映される事になりました。

とはいえ、実はこのパッチで追加されるコードは皆さんのお手元にインストールされるFirefoxには反映されません。何故なら、これは自動テストを追加するだけのパッチだからです。

パッチが投入されるまでの経緯

この報告は、「Firefoxのアドオン開発に使用するWebExtensionsのいくつかのAPIにおいて、タブの位置を示すindexというプロパティが不正な値になる場合がある」という物でした。ただ、これは実はWebExtensions APIの実装の問題ではなく、Firefoxのタブが内部的に持っている対応する情報自体が壊れていたというのが真相でした。

(図:壊れた内部状態が露出した事が原因で、APIの情報が壊れている様子)

「根本的な原因」の方は Bug 1504775 - Index is wrong for restored tabs という別のBugで取り扱われていました。今回の報告と前後してそちらのBugの作業が進行していたため、そちらの方で修正が行われた事の影響として、報告からあまり間を置かずにWebExtensions APIの不具合も自然解消したという状況になっていました。

一般的に、このような「ある報告の原因が別の報告の修正で解消された」というようなケースでは、その旨を記して修正済み扱いとしたり、もしくは重複する報告であるとして処理する事が多いです。

(図:2つのBugに因果関係がある時、両方が閉じられる様子)

このような場合、そちら側に提出されたパッチはBugのクローズに伴って破棄されがちです。しかし今回はそうではなく、Bugはクローズされずに留め置かれ、新たに自動テストを加えるだけのパッチを追加投入する事が承認されました。

これは、根本原因の方のBugがFirefox内部の問題のみを取り扱う物であったのに対し、こちらで報告したBugはアドオンという外部アプリケーションに対する互換性の問題を取り扱っていたからです。

テストは目的ごとに必要

根本原因が修正されたパッチでは、Firefox内部のAPIに対するテストケースは追加されていますが、WebExtensionsのテストケースには特に変更は加えられていませんでした。

今回はFirefoxがたまたま「タブの内部的なindexがそのままWebExtensions APIを通じてアドオンに露出する」という実装を取っていたために、WebExtensions API側の問題も偶然解消されました。しかし、それはあくまで現時点の実装がそうであったからというだけで、将来に渡ってそうであるという事の保証はどこにもありません

(図:内部向けの保証はあってもアドオン相手の保証は無いという様子)

よって、今後Firefox内部の実装やWebExtensions APIの実装が変更された場合に、人知れず再び同じ問題が起こるようになるという可能性は依然としてあります。

そのため、今回のパッチでWebExtensions APIのレイヤにおいて後退バグ*1が発生していないかを検出するための回帰テスト*2を追加することで、WebExtensions APIに意図せず影響を与えてしまう(そのような変更が気付かれずに投入されてしまう)という事故の発生を未然に防ぎ、APIの安定性を保証しやすくしたという事になります。

(図:保証する相手ごとにテストがある様子)

気をつけて欲しいのは、これは「単体テストと結合テストではレイヤが違うので、単体テストで検証済みの事もすべて結合テストで再検証しよう」という話ではない、という事です。同じ事を保証するテストがそれぞれの実装レイヤに含まれるという事はあり得ますが、それはあくまで結果的にそうなっているだけに過ぎません。テストは誰に対して何を保証するか、という観点で整備される事が重要です。

今回は「Firefox内部向け」と「アドオンという外部アプリケーション向け」のそれぞれで修正・互換性を保証する必要があったためにこうなったわけです。

まとめ

Firefoxに投入された自動テストのみのパッチの背景説明を通じて、テストの目的とテストを追加するかどうかの判断基準の一例をご紹介しました。

自動テストの追加は、ドキュメントの修正に次いで「外部のコントリビューターとして開発に参加する最初のステップ」として取り組みやすい作業です。自動テストが全く存在しないプロダクトや、自動テストがあっても特定の機能についてテストが不足しているというケースに遭遇したら、皆さんもぜひテストの追加に取り組んでみて下さい。独りでやりきるには不安があるという方は、スケジュールが合うOSS Gateのワークショップに参加してサポーターの人に相談してみても良いかもしれません。

*1 今まで期待通りに動いていた物が、別の変更の影響で期待通りに動かなくなってしまう事。

*2 ある問題を修正したときに、問題が発生しなくなっている事を検証するテストのこと。

2019-04-23

FirefoxやChromiumのアドオン(拡張機能)で効率よくタブを並べ替えるには

この記事はQiitaとのクロスポストです。

Firefox 57以降のバージョンや、Google Chromeをはじめ各種のChromiumベースのブラウザでは、アドオン開発に共通のAPIセットを使用します。タブバー上のタブを任意の位置に移動するAPIとしてはtabs.move()という機能があり、これを使うと、タブを何らかのルールに基づいて並べ替えるという事もできます。実際に、以下のような実装例もあります。

このようなタブの並べ替え機能を実現する時に気をつけたいポイントとして、 いかにAPIの呼び出し回数を減らし、効率よく、且つ副作用が少なくなるようにするか? という点があります。

ソートアルゴリズムの効率とは別の話

「並べ替え」「効率」というキーワードでソートアルゴリズムの事を思い浮かべる人もいるのではないでしょうか。確かにそれと似ているのですが、実際にはこの話のポイントはそこにはありません。

確かに、ソート自体は前述の2つのアドオンでも行っており、

  • バラバラの順番のタブをコンテナごとに整理する
  • URLなどの条件でタブの順番を整理する

という処理はまさにソートそのものです。しかし、現代のJavaScript実行エンジンは充分に高速なため、余程まずいアルゴリズムでもない限りJavaScriptで実装されたソートの速度自体は実用上の問題となりにくいです。

それよりも、その後に行う 「実際のタブを移動して、算出された通りの順番に並べ替える」 という処理の方が影響度としては深刻です。「タブ」というUI要素を画面上で移動するのは、純粋なデータのソートに比べると遙かにコストが大きいため、数百・数千個といったオーダーのタブをすべて並べ替えるとなると相応の時間を要します。また、タブが移動されたという事はイベントとして他のアドオンにも通知され、その情報に基づいて様々な処理が行われ得ます*1ので、そのコストも馬鹿になりません*2

タブの並べ替えには手間がかかる

実際の並べ替えの様子を図で見てみましょう。[i, h, c, d, e, f, g, b, a, j] という順番のタブを、[a, b, c, d, e, f, g, h, i, j] に並べ替えるとします。

(図:並べ替え前後)

ぱっと見で気付くと思いますが、これは a, b, h, i の4つを左右で入れ換えただけの物です。しかし、人間にとっては一目瞭然の事でも、プログラムにとっては自明な事ではありません(それを検出するのがまさにこの記事の本題なのですが、詳しくは後述します)。

最もナイーブな*3実装としては、元の並び順からタブを1つづつ取り出していくやり方が考えられます。その場合、行われる操作は以下のようになります。

(図:ナイーブな並べ替えの流れ。計8回の移動が生じている様子。)

というわけで、この場合だと10個のタブの並べ替えに8回の移動が必要となりました。元のタブの並び順次第では、移動は最大で9回必要になります。

ちなみに、tabs.move()は第一引数にタブのidの配列を受け取ることができ、その場合、第一引数での並び順の通りにタブを並べ替えた上で指定の位置に移動するという挙動になります。そのため、

browser.tabs.move([a, b, c, d, e, f, g, h, i, j], { index: 0 });

とすれば、APIの呼び出し回数としては確かに1回だけで済ませる事もできます。ただ、これは内部的には

browser.tabs.move(a, { index: 0 });
browser.tabs.move(b, { index: 0 + 1 });
browser.tabs.move(c, { index: 0 + 2 });
browser.tabs.move(d, { index: 0 + 3 });
browser.tabs.move(e, { index: 0 + 4 });
browser.tabs.move(f, { index: 0 + 5 });
browser.tabs.move(g, { index: 0 + 6 });
browser.tabs.move(h, { index: 0 + 7 });
browser.tabs.move(i, { index: 0 + 8 });
browser.tabs.move(j, { index: 0 + 9 });

といった移動操作と同等に扱われるため、タブが実際に移動されるコストは変わらず、タブが移動された事を通知するイベントもその都度通知されます。

ソートと同時にタブを移動するのではどうか?

ここまではArray.prototype.sort()でソートした結果に従って、後からまとめてタブを並べ替えるという前提で説明していました。では、Array.prototype.sort()を使わずに独自にソートを実装し、その並べ替えの過程で同時にタブも移動する、という形にするのはどうでしょうか?

例えばこちらのクイックソートの実装の過程にtabs.move()を呼ぶ処理を追加すると、以下のようになります。

// 再帰によるクイックソートの実装
function quickSort(tabIds, startIndex, endIndex) {
  const pivot = tabIds[Math.floor((startIndex + endIndex) / 2)];
  let left = startIndex;
  let right = endIndex;
  while (true) {
    while (tabIds[left] < pivot) {
      left++;
    }
    while (pivot < tabIds[right]) {
      right--;
    }
    if (right <= left)
      break;
    // 要素の移動時に、同じ移動操作をタブに対しても行う
    browser.tabs.move(tabIds[left], { index: right });
    browser.tabs.move(tabIds[right], { index: left });
    const tmp = tabIds[left];
    tabIds[left] = tabIds[right];
    tabIds[right] = tmp;
    left++;
    right--;
  }
  if (startIndex < left - 1) {
    quickSort(tabIds, startIndex, left - 1);
  }
  if (right + 1 < endIndex ){
    quickSort(tabIds, right + 1, endIndex);
  }
}
const tabs = await browser.tabs.query({windowId: 1 });
quickSort(tabs.map(tab => tab.id), 0, tabIds.length - 1);

先の [i, h, c, d, e, f, g, b, a, j] を [a, b, c, d, e, f, g, h, i, j] に並べ替えるという例で見てみると、

(図:クイックソートでの並べ替えの流れ。計4回の移動が生じている様子。)

このように、今度は4回の移動だけで並べ替えが完了しました。先の場合に比べると2倍の高速化なので、これは有望そうに見えます。

……が、そう考えるのは早計です。この例で並べ替えが4回で終わったのは、部分配列の左右端から要素を見ていくというアルゴリズムが、左右の端に近い要素が入れ替わる形になっていた [i, h, c, d, e, f, g, b, a, j] という元の並び順に対して非常にうまく作用したからに過ぎません。例えば入れ替わった要素の位置が左右の端から遠い位置にある [c, d, i, h, e, b, a, f, g, j] のようなケースでは、

(図:クイックソートでの並べ替えの流れ。計12回の移動が生じている様子。)

と、今度は12回も移動が発生してしまいます。クイックソートは場合によっては同じ要素を何度も移動する事になるため、タブの移動のコストが大きい事が問題となっている今回のような場面では、デメリットの方が大きくなり得るのです。

また、クイックソートには「ソート結果が安定でない」という性質もあります。各要素のソートに使う値がすべて異なっていれば並べ替え結果は安定しますが、Firefox Multi-Account Containersでの「タブのコンテナごとに整理する」というような場面では、同じグループの要素の順番がソートの度に入れ替わってしまうという事になります。このような場合は要素間の比較の仕方を工夫するか、別のソートアルゴリズムを使う必要があります。

どうやら、このアプローチでは一般的に効率よくタブを並べ替えるという事は難しいようです。一体どうすれば「人が目視でするように、可能な限り少ない移動回数で、タブを目的の順番に並べ替える」という事ができるのでしょうか。

diffのアルゴリズムを応用する

このような問題を解決するには、diff編集距離といった考え方が鍵となります。

Unix系の環境に古くからあるdiffというコマンドを使うと、2つの入力の差を表示する事ができます。Gitなどのバージョン管理システムにおいてもgit diffのような形で間接的に機能を利用できるようになっているため、各コミットで行った変更点を見るために使った事がある人は多いでしょう。例えば、A = [a, b, c, d] と B = [a, c, d, b] という2つの配列があったとして、AとBの差分はdiffでは以下のように表されます。

  a
- b
  c
  d
+ b

行頭に-が付いている行は削除(AにはあってBには無い)、+が付いている行は追加(Aには無くてBにはある)を意味していて、これを見れば、「Aのbを2番目から4番目に移動する」というたった1回の操作だけでAをBにできるという事が見て取れます。このような「最小の変更手順」を求めるのが、いわゆる「diffのアルゴリズム」です。

つまり、これを「タブの並べ替え」に当てはめて、 「現時点でのタブの並び順」と「期待される並び順」とを比較し、算出された差分の情報に則ってタブを実際に移動するようにすれば、最小の手間でタブを並べ替えられる というわけです。

JavaScriptでのdiffの実装

diffの実装というとdiffコマンドが真っ先に思い浮かぶ所ですが、diffをライブラリとして実装した物は各言語に存在しており、もちろんJavaScriptの実装もあります。以下はその一例です。

diffのアルゴリズムをタブの並べ替えに使うには、こういったライブラリをプロダクトに組み込むのがよいでしょう。ただ、その際には以下の点に気をつける必要があります。

  • ライブラリのライセンスが他の部分のライセンスと競合しない事。
  • ライブラリの内部表現を機能として利用できる事。
  • 入力として任意の要素の配列を受け受ける事。

ライセンスの事は当然として、内部表現が利用できるとはどういう事でしょうか。

前述の例の「行頭に-+が付く」という表現は、git diffなどで目にする事の多い「Unified Diff」形式に見られる表記ですが、これはあくまで最終出力です。ライブラリ内部では、相当する情報を構造化された形式で持っている事が多いです。npmのdiffであるjsdiffの場合は以下の要領です。

[
  { count: 1, value: [a] },                // 共通の箇所
  { count: 1, value: [b], removed: true }, // 削除された箇所
  { count: 2, value: [c, d] },             // 共通の箇所
  { count: 1, value: [b], added: true }    // 追加された箇所
]

先のテキストでの表現をパースしてこのような表現を組み立てる事もできますが、ライブラリの内部表現→出力結果のテキスト表現→内部表現風の表現 という無駄な変換をするよりは、素直に内部表現をそのまま使えた方が話が早いです。

また、ライブラリの入力形式には任意の配列を受け付ける物である方が望ましいです。文字列のみを入力として受け付けるライブラリでも使えなくはないのですが、内部ではどの実装も基本的に配列を使っています。こちらも、比較したい配列→ライブラリに与えるための文字列→内部表現の配列 という無駄な変換が必要の無い物を使いたい所です。

npmのdiffパッケージであるjsdiffは、これらの点で使い勝手が良いのでおすすめの実装と言えます。

diffのアルゴリズムを使ったタブの並べ替え

diffを使ってタブを並べ替えるには、「ソート前の配列」「ソート後の配列」に「作業中の状態を保持する配列」を加えた3つの配列を使います。

const tabs = await borwser.tabs.query({ currentWindow: true });

const beforeIds = tabs.map(tab => tab.id); // ソート前のタブのidの配列
const afterIds  = tabs.sort(comparator)
                      .map(tab => tab.id); // ソート後のタブのidの配列
let   currentIds = beforeIds.slice(0);     // 現在の状態を保持する配列(ソート前の状態で初期化)

次に、ソート前後のタブのidの配列同士の差分を取り、その結果に従ってタブを移動していきます。

const differences = diff.diffArrays(beforeIds, afterIds);
for (const difference of differences) {
  if (!difference.added) // 削除または共通の部分は無視する
    continue;
  // ここにタブの移動処理を書いていく
}

タブの移動による並べ替えという文脈では、「削除」の差分で操作されるタブは対応する「追加」の操作によっても操作されます。また、「なくなるタブ(=処理中に閉じなければいけないタブ)」は原則として存在しません。よって、差分情報のうち「共通」と「削除」は無視して、「追加」にあたる物のみを使用します。

「追加」の差分情報を検出したら、まずタブの移動先位置を計算します。tabs.move()はタブの位置をindexで指定する必要がありますが、この時のindexは以下の要領で求められます。

  // 以下、forループ内の処理

  // 移動するタブ(1個以上)
  const movingIds = difference.value;
  // 移動するタブ群の中の右端のタブを得る
  const lastMovingId = movingIds[movingIds.length - 1];
  // ソート後の配列の中で、移動するタブ群の右に来るタブの位置を得る
  const nearestFollowingIndex = afterIds.indexOf(lastMovingId) + 1;
  // 移動するタブ群がソート後の配列の最後に来るのでないなら、
  // 移動するタブ群の右に来るタブのindexを、タブ群の最初のタブの移動先位置とする
  let newIndex = nearestFollowingIndex < afterIds.length ? currentIds.indexOf(afterIds[nearestFollowingIndex]) : -1;
  if (newIndex < 0)
    newIndex = beforeIds.length;

  // 移動するタブ群の左端のタブの現在のindexを調べる
  const oldIndex = currentIds.indexOf(movingIds[0]);
  // 現在の位置よりも右に移動する場合、移動先の位置を補正する
  if (oldIndex < newIndex)
    newIndex--;

タブ群の移動先の位置を求めたら、いよいよtabs.move()でタブを移動します。このAPIは第1引数にタブのidの配列を渡すと複数のタブを一度に指定位置に移動できるので、APIの呼び出し回数を減らすためにもそのようにします。

  // 実際にタブを移動する
  browser.tabs.move(movingIds, {
    index: newIndex
  });
  // 現在の状態を保持する配列を、タブの移動後として期待される状態に合わせる
  currentIds = currentIds.filter(id => !movingIds.includes(id));
  currentIds.splice(newIndex, 0, ...movingIds);

タブの移動はAPIを介して非同期に行われますが、タブの移動指示そのものはまとめて一度にやってしまって構いません。むしろ、途中でtabs.move()の完了をawaitで待ったりすると、タブの並べ替えの最中に外部要因でタブが移動されてしまうリスクが増えるので、ここはバッチ的に同期処理で一気にやってしまうのが得策です。

ただしその際は、2回目以降のtabs.move()に指定するタブの移動後の位置として、前のtabs.move()の呼び出しで並べ替えが完了した後の位置を指定しなくてはならないという点に注意が必要です。そのため、ソート前後の配列とは別に「並べ替えが実施された後の状態」を保持する配列を別途用意しておき、それだけを更新しています。前述のコード中で「タブの現在の位置」を求める際に、tabs.Tabindexを参照せずにこの配列内での要素の位置を使用していたのは、これが理由なのでした。

実際の並べ替えの様子を、最初と同じ例を使って見てみましょう。[i, h, c, d, e, f, g, b, a, j] を [a, b, c, d, e, f, g, h, i, j] に並べ替えるとします。初期状態は以下のようになります。

  • beforeIds: [i, h, c, d, e, f, g, b, a, j]
  • afterIds: [a, b, c, d, e, f, g, h, i, j]
  • currentIds: [i, h, c, d, e, f, g, b, a, j]
  • 実際のタブ: [i, h, c, d, e, f, g, b, a, j]

この時、beforeIdsafterIdsを比較して得られる差分情報は以下の通りです。

// 実際にはvalueはタブのidの配列だが、分かりやすさのためにここではアルファベットで示す
[
  { count: 2, value: [i, h], removed: true },
  { count: 2, value: [a, b], added: true },
  { count: 5, value: [c, d, e, f, g] },
  { count: 2, value: [b, a], removed: true },
  { count: 2, value: [h, i], added: true },
  { count: 1, value: [j] }
]

前述した通り、ここで実際に使われる差分情報は「追加」にあたる以下の2つだけです。

  1. { count: 2, value: [a, b], added: true }
  2. { count: 2, value: [h, i], added: true }

まず1つ目の差分情報に基づき、aとbを移動します。移動先の位置は、bの右隣に来る事になるタブ(=c)のcurrentIds内での位置ということで、2になります。

(図:diffを使った並べ替えの流れ。まず2つのタブを移動する。)

この時、b, aだった並び順も併せてa, bに並べ替えています。タブの移動完了後は、それに合わせる形でcurrentIdsも [i, h, a, b, c, d, e, f, g, j] へと更新します。

次は2つ目の差分情報に基づき、hとiを移動します。移動先の位置は、iの右隣に来る事になるタブ(=j)のcurrentIds内での位置から求めます。jの位置は9ですが、これはhの現在位置より右なので、そこから1減らした8が最終的な移動先位置になります。

(図:diffを使った並べ替えの流れ。次も2つのタブを移動する。)

この時も、i, hだった並び順をh, iに並べ替えています。タブの移動完了後は、それに合わせる形でcurrentIdsも [a, b, c, d, e, f, g, h, i, j] へと更新します。

ということで、APIの呼び出しとしては2回、タブの移動回数としては4回で並べ替えが完了しました。冒頭の例の8回に比べるとタブの移動回数は半分で済んでいます。

クイックソートでは却って移動回数が増えてしまった、[c, d, i, h, e, b, a, f, g, j] からの並べ替えではどうでしょうか。この場合、比較結果は以下のようになります。

[
  { count: 2, value: [a, b], added: true },
  { count: 2, value: [c, d] },
  { count: 2, value: [i, h], removed: true },
  { count: 2, value: [e] },
  { count: 2, value: [b, a], removed: true },
  { count: 2, value: [f, g] },
  { count: 2, value: [h, i], added: true },
  { count: 1, value: [j] }
]

前述の通り、このうち実際に使われる差分情報は以下の2つだけです。

  1. { count: 2, value: [a, b], added: true }
  2. { count: 2, value: [h, i], added: true }

これは先の例と全く同じなので、APIの呼び出し回数は2回、実際のタブの移動は4回で済みます。

(図:diffを使った並べ替えの流れ。タブを4回移動している様子。)

これらの例から、diffのアルゴリズムを用いると安定して少ない移動回数でタブを並べ替えられるという事が分かります。diffのアルゴリズムを用いた差分の計算自体はタブの移動に比べれば一瞬で済みますので、タブの移動回数を少なく抑えられれば充分に元が取れると言えるでしょう。

まとめ

以上、diffのアルゴリズムを用いてブラウザのタブを効率よく並べる方法について解説しました。

冒頭で例として挙げたタブの並べ替え機能を持つアドオンに対しては、以上の処理を実際に組み込むプルリクエストを既に作成済みです。

これらの変更(およびこの記事)は、「ツリー型タブ」での内部的なタブの並び順に合わせてFirefoxのタブを並べ替える処理が元になっています。ツリー型タブではnpmのdiffとは別のdiff実装を使用していますが、diffの結果として得られる内部表現の形式はよく似ており、タブの並べ替え処理そのものは同じロジックである事が見て取れるはずです。

このように「最小の変更内容を計算して適用する」という考え方は、Reactで広まった仮想DOMという仕組みのベースとなっています。コンピューターが充分に高性能となり、富豪的プログラミングが一般化した現代においては、多少の事は力業で解決してしまって問題無い事が多いです。しかし、ネットワーク越しの処理や、携帯端末などデスクトップPCよりも性能が制限されがちな環境などで、何千・何万回といった単位で実行されるような処理にボトルネックがあると致命的な性能劣化に繋がりかねず、そのような場合には「コストの高い処理の実行頻度を減らしてトータルの性能を向上する」という正攻法での性能改善が依然として有効です。

なお、2者間で「変更箇所を検出する」というのは「共通部分を検出する」という事と表裏一体ですが、そのような計算は最長共通部分列問題と呼ばれ、diffのアルゴリズムの基礎となっています。どのような理屈で共通箇所を検出しているのか知りたい人は解説や論文を参照してみると良いでしょう。筆者は理解を諦めましたが……

*1 例えば、タブをツリー状の外観で管理できるようにするアドオンであるツリー型タブでは、親のタブが移動された場合は子孫のタブもそれに追従して移動されますし、既存のツリーの中にタブが出現した場合や既存のツリーの一部だけが別の場所に移動された場合などにも、ツリー構造に矛盾が生じなくなるようにタブの親子関係を自動的に調整するようになっています。

*2 ブラウザ自体のタブの並べ替えにかかる時間の事よりも、むしろ、こちらの副作用の方が問題だと言っていいかもしれません。

*3 愚直な。

2019-04-19

Firefox 67 以降でPolicy Engineによるポリシー設定でPOSTメソッドの検索エンジンを追加できるようになります

Firefoxのポリシー設定では、以下の要領でロケーションバーやWeb検索バー用の検索エンジンを登録することができます。

{
  "policies": {
    "SearchEngines": {
      "Add": [
        {
          "Name":               "検索エンジンの表示名",
          "IconURL":            "https://example.com/favicon.ico",
          "Alias":              "ex",
          "Description":        "検索エンジンの説明文",
          "Method":             "GET",
          "URLTemplate":        "https://example.com/search?q={searchTerms}",
          "SuggestURLTemplate": "https://example.com/suggest?q={searchTerms}"
        }
      ]
    }
  }
}

Firefox 66以前のバージョンではこの時、HTTPのメソッドとしてGETのみ使用可能で、POSTメソッドを要求する検索エンジンは登録できないという制限事項がありました。OpenSearchの検索プラグインではPOSTの検索エンジンも登録できたので、これはPolicy Engineだけの制限ということになります。

RESTの原則からいうと「何回検索しても同じ結果が返る事が期待される検索結果一覧ページを表示するためのリクエストはGETが当然」という事になるのですが、実際にはFirefoxのロケーションバーやWeb検索バーは「検索」に限らず、入力された語句を含む任意のHTTPリクエストを送信する汎用のフォームとして使えます。よって、適切な内容の「検索エンジン」を登録しておきさえすれば、FirefoxのUI上から直接「Issueの登録」や「チャットへの発言」を行うといった応用すらも可能となります。しかし、そういった「新規投稿」にあたるリクエストはPOSTメソッドを要求する場合が多いため、それらは残念ながらPolicy Engine経由では登録できませんでした。

この件について 1463680 - Policy: SearchEngines.Add cannot add effective search engine with POST method として報告していたのですが、最近になって「バックエンドとなる検索エンジン管理の仕組みの改良によって、仕組み的にはPOSTメソッドも受け取るようになったので、パッチを書いてみては?」と促されました。そこでさっそく実装してみた所、変更は無事マージされ、Firefox 67以降では以下の書式でPOSTメソッドとそのパラメータを指定できるようになりました。

{
  "policies": {
    "SearchEngines": {
      "Add": [
        {
          "Name":        "検索エンジンの表示名",
          "IconURL":     "https://example.com/favicon.ico",
          "Alias":       "ex",
          "Description": "検索エンジンの説明文",
          "Method":      "POST",
          "URLTemplate": "https://example.com/search",
          "PostData":    "q={searchTerms}"
        }
      ]
    }
  }
}

"Method"の指定を"POST"にする事と、"URLTemplate"ではなく"PostData"の方に{searchTerms}というパラメータを指定する事がポイントです。

今回の変更自体は別のBugでの改良に依存しているため、ESR60に今回の変更がupliftされるためには、依存する変更も併せてupliftされる必要があります。ESR版自体のメジャーアップデートも視野に入りつつあるこの時期ですので、そこまでの手間をかけてこの変更がESR60に反映されるかどうかについては、現状では何とも言えません。とはいえ、次のESRのメジャーアップデート以降で使用可能になる事は確実です。もしこの機能をお使いになりたいというESR版ユーザーの方がいらっしゃいましたら、期待してお待ちいただければと思います。

2019-03-01

Firefox ESR60でタイトルバーのウィンドウコントロールボタンが機能しなくなる事がある問題の回避方法

Firefox ESR60をWindows 7で使用していると、ウィンドウコントロール(タイトルバーの「最小化」「最大化」「閉じる」のボタン)が動作しなくなる、という現象に見舞われる場合があります。Firefoxの法人サポート業務の中でこの障害についてお問い合わせを頂き、調査した結果、Firefox自体の不具合である事が判明しました。

この問題は既にMozillaに報告済みで、Firefox 67以降のバージョンで修正済みですが、Firefox ESR60では修正されない予定となっています。この記事では、Firefox ESR60をお使いの方向けに暫定的な回避方法をご案内します。

問題の状況とその原因

この現象は、FirefoxのUIの設計に由来する物です。

一般的なWindowsアプリケーションは、タイトルバーなどを含めたウィンドウの枠そのものはWindowsに描画や制御を任せて、枠の内側だけでUIを提供します。それに対し、Firefoxのブラウザウィンドウでは、タイトルバー領域に食い込む形でタブを表示させるため、タイトルバーを含むウィンドウの枠まで含めた全体を自前で制御しています。そのため、タイトルバー領域に食い込む形でタブが表示されている場面では、Windowsが標準で提供しているウィンドウコントロールのボタンは実は使われておらず、それを真似る形で置かれた独自のUI要素で代用しています。

通常、これらの代用ボタンは他のUI要素よりも全面に表示されるため、タブなどの下に隠れる事はなく、ユーザーは見たままの位置にあるボタンをクリックできます。しかし、特定の条件下ではこれらの代用ボタンが他のUI要素の下に隠れてしまう形となり、ボタンをクリックできなくなってしまいます。

この問題が起こる条件は、以下の通りです。

  • Windows 7で、Windows自体のテーマとして「Classic」テーマを使用している。
  • ツールバー上の右クリックメニューから「メニューバー」にチェックを入れており、メニューバーが常時表示される状態になっている。
  • ウィンドウがwindow.open()で開かれ、その際、メニューバーを非表示にするように指定された。

Windowsのテーマ設定とFireofxのツールバーの設定を整えた状態で、w3schools.comのwindow.oepn()の各種指定のサンプルを開き「Try it」ボタンをクリックしてみて下さい。実際に、ボタンが機能しないためウィンドウを閉じられなくなっている*1事を確認できるはずです。

問題が再現した時のFirefoxの内部状態を詳しく調査すると、Classicテーマにおいては、この状況下ではタブバーのz-index(重ね合わせの優先順位)が2になるのに対し、ウィンドウコントロールのz-indexは常に1になるために、タブバーがウィンドウコントロールの上に表示されてしまっている状態である*2という事が分かりました。

なお、ClassicテーマはWindows 7以前のバージョンでのみ使用できる機能で、Windows 10以降では使えません*3。そのため、この問題はWindows 10以降では再現しません。

回避方法

原因が分かれば対策は容易です。

最も単純な対策は、ユーザープロファイル内にchromeという名前でフォルダを作成し、以下の内容のファイルをuserChrome.cssの名前で設置するというものです。

@namescape url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");

/* WindowsでClassicテーマが反映されている場合、 */
@media (-moz-windows-classic) {
  /* タイトルバーにタブを表示する設定で、フルスクリーン表示でない時は、 */
  #main-window[tabsintitlebar]:not([sizemode=fullscreen]) #titlebar-buttonbox {
    /* ウィンドウコントロールの重ね合わせ順位をタブバーよりも上にする */
    z-index: 3 !important;
  }
}

ただ、ユーザープロファイルの位置はFirefoxの実行環境ごとに異なる*4ため、管理者側でこの対応を全クライアントに反映するのは難しいです。クライアント数が多い場合は、Firefoxのインストール先のchromebrowser\chrome配下に置かれたCSSファイルを自動的に読み込むためのスクリプトMCD用設定ファイルに組み込むなどの方法をとるのがおすすめです。

修正の状況

原因が判明した時点で、本件はFirefox本体の問題としてbugzilla.mozilla.orgに以下の通り報告しました。

また、調査を行った時期にちょうどFirefoxのタブバー・タイトルバー周りの実装の仕方が変化していたため、最新の開発版でも状況を確認した所、Firefox 65以降ではWindows 7でなくても同様の問題が起こり、しかも今度はそもそもウィンドウコントロールが表示されなくなってしまっているという、より酷い状況でした。そのため、そちらは別の問題として以下の通り報告しました。

Firefox ESR60およびFirefox 64以前での問題(本記事で解説している問題)については、Firefox 65でタブバーの設計が変わったために現象としては再現しなくなった事と、セキュリティに関わる問題ではない事から、修正はされないという決定がなされています。

Firefox 65以降での問題については、既に修正のためのパッチを提供し、Firefox 67以降のバージョンに取り込まれる事が確定しています。Firefox 65に対しては修正はバックポートされず、Firefox 66については特に誰も働きかけなければ修正は反映されないままとなる見込みです。

まとめ

以上、Firefoxの法人向け有償サポートの中で発覚したFirefoxの不具合について暫定的な回避方法をご案内しました。

当社のフリーソフトウェアサポート事業では、当社が開発した物ではないソフトウェア製品についても、不具合の原因究明、暫定的回避策のご提案、および(将来のバージョンでの修正のための)開発元へのフィードバックなどのサポートを有償にて行っております。Firefoxのようなデスクトップアプリケーションだけでなく、サーバー上で動作する各種ソフトウェアについても、フリーソフトウェアの運用でトラブルが発生していてお困りの企業のシステム管理担当者さまがいらっしゃいましたら、メールフォームよりご相談下さい。

*1 そのため、ウィンドウを閉じるにはCtrl-F4などのキーボードショートカットを使うなどの、ボタンを使わない方法を使う必要があります。

*2 タブバーの右端はあらかじめウィンドウコントロールを重ねて表示するために余白領域が設けられていますが、現象発生時には、この余白領域の上ではなく下にウィンドウコントロールが表示されており、「ボタンは見えているのにその手前に透明な壁があってクリックできない」というような状況が発生しているという状況です。

*3 Classicテーマ風のテーマは存在していますが、Windows 7以前のそれとは異なり、単に配色等をClassicテーマ風にするだけの物です。

*4 安全のため、パスにランダムな文字列が含まれる形になっています。

2019-02-15

Gecko Embedded: 60ESR対応のフィードバック

60ESR対応フィードバックの顛末

クリアコードでは Gecko(Firefox)を組み込み機器向けに移植する取り組みを行っています。

この対応にあたっては OSSystems/meta-browser というYoctoレイヤをフォークして作業を行っていますが、対応が落ちついたバージョンについてはアップストリーム(OSSystems/meta-browser)に成果をフィードバックしています。例えば52ESRへのメジャーバージョンアップの対応はクリアコードの成果が元となっています。

さて、60ESRへのメジャーバージョンアップ対応についてもフィードバックをしていたのですが、最後まで自分たちでは解決できなかった問題が一つありました。今回は、OSSにはこんなフィードバックの仕方もあるのだという事例として紹介したいと思います。

meta-rustへの依存

52ESRから60ESRへの更新にあたって一番大きな問題となったのが、Firefox本体にRustで書かれたコードが導入された点です。通常、Firefoxをビルドする際にはrustupというコマンドでツールチェーンを導入します。クリアコードで60ESRのYoctoへのポーティングを開始した当初も、Rustのツールチェーンについてはrustupで導入したものを使用していました。

しかし、Yoctoでは一貫したクロスビルド環境を提供するために、ツールチェーン類も全てYoctoでビルドするのが基本です。このため、RustのツールチェーンについてもYoctoでビルドした物を使う方がよりYoctoらしいと言えます。調べてみたところ、既にmeta-rustというYoctoレイヤが存在していることが分かりましたので、これを利用することとしました。

ターゲット環境用のlibstd-rsを参照できない問題

meta-rustへの対応作業を進めていく中で、Firefoxのビルドシステムがターゲット環境用のlibstd-rsを発見してくれないという問題に遭遇しました。しかし、我々はRustのビルドの仕組みには不慣れであったため、それがどこの問題であるかをはっきりと切り分けることができていませんでした。一方で、meta-rustでのビルドの際に、libstd-rsのみビルド済みのものを導入することで、ひとまずこの問題を突破できることが分かりました。

その他発生していた問題については解決することができ、Firefoxのビルドが全て通るところまでは到達しました。

上記の回避策が正しい修正方法ではないということは分かっていたのですが、ではどこでどう修正をするのが正しいのかという点については、少し調査をしてみた限りでは判断がつかない状態でした。時間をかければいずれは解明できるのは間違いないのですが、我々には他にも取り組まなければいけない問題が山積していたため、その時点では、これ以上時間をかけることはできませんでした。

ひとまずOSSystems/meta-browserに対しては「こういう問題があってまだ調査中だから、マージするのはもうちょっと待ってね」という形でpull requestを投げておきました。

未解決のままマージされる

ところがしばらくすると、 Yocto/Open EmbeddedプロジェクトのKhem Raj氏が、OSSystems/meta-browserのメンテナであるOtavio Salvador氏に働きかけ、上記のlibstd-rsの問題が未解決であるにも関わらず、このpull requestをmasterにマージしてしまいました。上記の回避策はmeta-rustにはマージされていませんので、そのままではビルドを通すことすらできません。私の目から見るとちょっとした暴挙にも見えたのですが、状況についてはKhem Raj氏にも間違いなく伝わっているので、何か算段があるのだろうということでしばらく経過を見守ることにしました。

あとから分かったのですが、ちょうどYoctoの次のリリース向けの開発が始まるタイミングであったため、とりあえずmasterにマージしてCIに組み込んでしまってからの方が問題に取り組みやすいという事情があったようです。しばらく時間はかかりましたが、無事Khem Raj氏から問題を解決するpull requestが投げられました。

我々の方ではmeta-rustとmeta-browserのどちらで対処すべき問題なのかすら切り分けることができていませんでしたが、meta-browserで対処すべき問題だったようです。

本事例の感想

今回の件については、以下のような懸念から、ともすればフィードバックを躊躇してしまいがちな事例ではないかと思います。

  • このような中途半端なpull requestを送ると、アップストリームの開発者から罵倒されたりはしないか?
  • 「この程度の問題すら自分たちで解決できていない」ということを晒すことになるので、恥ずかしいなぁ。

前者については、確かにプロジェクトによってはそのようなpull requestが歓迎されないこともあるでしょう。とはいえ、今回の事例についてはそこそこ工数がかかる60ESRポーティング作業を粗方済ませており、その成果が有益と感じる開発者も多いはずです。また、後者については「聞くのは一時の恥、聞かぬは一生の恥」の良い事例ではないかと思います。結果的にはアップストリームの開発者の協力を得て問題を解決することもできて、成果をより多くの人に使ってもらえる状態にできたので、中途半端でもフィードバックをしておいて良かったなと感じています。

皆さんも「この程度のものは誰の役にも立たないだろう」「この程度のものを世に出すのは恥ずかしい」などと一人で勝手に思い込まないで、手元にある成果を積極的に公開してみてはいかがでしょうか?ひょっとすると、それが世界のどこかで悩んでいる開発者の問題を解決して、感謝してもらえるかもしれませんよ。

2019-01-24

片手でのキーボード入力を支援するソフトウェアについて

はじめに

怪我などで手首などを負傷してしまうと、満足に両手でのタイピングが行えない事態に陥ることがあります。
今回は、そんなときでも怪我した側の手にあまり負担をかけずに入力するための方法を紹介します。*1

Half KeyboardもしくはHalf QWERTY

片手だけで入力できるようにしようというアイデアはそう目新しいものではなく、昔からあるようです。

Half KeyboardもしくはHalf QWERTYなどで検索するといろいろでてくるので興味があれば調べてみるのもよいでしょう。
専用のキーボードが製品化されていたり、ソフトウェアで特定のキーが押されたらレイアウトを変更するというものもあります。

Mirrorboardの場合は、キー配列の定義を差し替えることでCapsLockを押したらキーレイアウトが左右反転するようになっています。

xhkによる片手キーボード入力

専用のキーボードの購入は敷居が高いので、ソフトウェアによる片手入力を試してみましょう。
今回そのうちの一つであるxhkを使ってみます。

xhkは次の手順でインストールできます。

% git clone https://github.com/kbingham/xhk.git
% cd xhk
% sudo apt install build-essential autoconf automake pkg-config
% sudo apt install libx11-dev libxi-dev libxtst-dev
% ./autogen.sh
% ./configure
% make
% sudo make install

使用するには、xhkコマンドを実行するだけです。(最初から反転した状態にするには -m オプションを指定します。)

% xhk

xhkを起動後、SPACEキーを入力するとレイアウトが左右反転するようになっています。

通常のキーレイアウト

SPACEキーを押したときのキーレイアウト

上が通常のキーレイアウトで、下がSPACEキーを押したときのキーレイアウトです。
SPACEキーを押すと、QのキーはPの位置にというように左右反転した位置に配置されます。そのため本来左手で押していたキーも右手で打てるようになっています。
(このキー配列の画像は、http://www.keyboard-layout-editor.com/ で作成しました。あらかじめ選択できる候補にHalf Keyboardがなかったので、フィードバック しておきました。)

例えば、「わたしの」(WATASHINO)と右手だけでローマ字入力したい場合には次のようにキーを押すと入力できます。

キー入力 印字内容
SPACE+O W
SPACE+; A
SPACE+Y T
SPACE+; A
SPACE+L S
H H
I I
N N
O O

入力したい文章によってはキーレイアウトを左右反転させるためにSPACEキーを頻繁に打つことになるので、右手をそれなりに酷使することになります。*2

なお、xhkがパッケージとしてインストールできると導入しやすくなって嬉しいだろうということで、パッケージ化の作業を進めやすくするためにフィードバックしておきました。またドキュメントの記述がやや古かったのでそちらも報告しておきました。

まとめ

今回は、片手でのキーボード入力を支援するソフトウェアのひとつであるxhkを紹介しました。
もし、片手でなんとかキーボード入力しないといけない事態になったら、選択肢の一つとして試してみるのもよいかもしれません。

*1 怪我をしたほうの手に負担をかけないことが主目的なので、怪我をしていないほうの手にはどうしても負荷がかかります。

*2 慣れないうちはキーレイアウトの変更に頭がついていかずにかなり打ち間違えたりします。

2018-11-21

ownCloud/Nextcloud関連プロダクトの翻訳に参加してみよう

最近ownCloud日本語翻訳チームのコーディネーターになってしまいました、結城です。

当社では長らく、業務のスケジュール管理にGoogleカレンダーを使っていていました。しかし、フリーソフトウェアを推進するという社の理念からすると、せっかくownCloudのような自由なライセンスの実装があるのにそちらを使っていないというのは望ましくありません*1。そこで最近、ownCloudの自社運用を開始しました。

ownCloudおよびその派生版のNextcloudは、DropboxやGoogle Driveなどのようなオンラインストレージを自社でホスティングするためのソフトウェアですが、アプリ(プラグイン)で様々な機能を追加でき、中でもカレンダー機能はGoogleカレンダーと似た感覚で使う事ができます。

ただ、実際に運用してみると細かいところで不満や不具合が見つかりました。そのため、当社では現在の所全社的に、業務と並行してそういった問題を解決していくためのフィードバックに取り組んでいます。

この記事ではその一つとして、翻訳への取り組みの紹介を兼ね、Transifexを使った翻訳作業の実際の流れや注意点を解説します。

翻訳に参加するまでの流れ

ownCloudのカレンダーを利用していると、イベントの繰り返し条件の設定画面に未訳箇所があるという点が目につきました。そこで、まずはできる所からという事で、翻訳に参加してみる事にしました。

OSS Gateワークショップでは「公式サイトからフィードバック先を見付けよう」という方針を取っていますが、今回は横着して、まずGoogleで「ownCloud translate」と検索してみました。すると、以下のページが最上位にヒットしました。

これはownCloud公式サイト上にある、翻訳コミュニティへの参加を呼びかけるドキュメントです。これによると、ownCloudの翻訳を行うにはTransifex上のownCloudチームに参加するように書かれています。

これにはまずTransifexのサイト上でアカウントを作成する必要があります*2が、この時「やりたいこと」の項目で「既存のプロジェクトに参加」を選択します。

(スクリーンショット:目的の選択時に「既存のプロジェクトに参加」を選ぶ)

その後、使用言語としてJapanese (ja)Japanese (Japan) (ja_JP)をそれぞれ追加します。Japanese (ja)は一般的な意味での「日本語」を、Japanese (Japan) (ja_JP)はその中でも特に「日本地域で使われる日本語」を指します*3

(スクリーンショット:言語の選択時に「Japanese (ja)」と「Japanese (Japan) (ja_JP)」を選ぶ)

アカウントができたらプロジェクト選択の画面に遷移しますが、この画面は一旦閉じて、ownCloudチームのダッシュボードを訪問します。

(スクリーンショット:ownCloudチームのダッシュボード)

「チームに参加」というボタンが表示されていますので、これをクリックすると、翻訳プロジェクトに参加する言語の選択が表示されます。「Japanese (Japan)」を選択して「参加」ボタンを押して下さい。

(スクリーンショット:言語選択の画面)

送信が完了すると、「Japanese (Japan) (ja_JP) 語に参加しました。 」という表示が出て、参加申請の承認待ちの状態になります。この後、プロジェクトの管理者がユーザーのプロフィールや活動実績などから判断して参加を承認してくれれば、晴れて翻訳作業に参加できるようになります。

実際の翻訳作業の進め方

参加申請が承認された後で再びownCloudチームのダッシュボードを訪れると、画面の左側にメニューが出るようになっており、また同時に、画面右側のボタンが「翻訳」に変化しています。

(スクリーンショット:参加承認後のダッシュボードの画面)

「翻訳」ボタンをクリックすると、言語とリソース(翻訳対象のアプリケーション)を選択する画面に遷移しますので、画面の指示に従ってそれぞれを選択すれば、翻訳の編集画面に遷移します。

(スクリーンショット:翻訳開始時のナビゲーション)

翻訳の編集画面(Transifexエディタ)では、以下の流れで翻訳を行います。

(スクリーンショット:翻訳作業の流れ)

  1. 未翻訳の文字列を選択
  2. 未翻訳の文字列一覧から、翻訳したい物を選択
  3. 選択した文字列が「未翻訳の文字列」欄に表示された事を確認
  4. 「翻訳文を入力してください」欄に訳文を入力
  5. 「翻訳を保存」ボタンで訳文を保存

保存された訳文は「レビュー待ち」という状態になり、レビュー権限がある人のレビューを通過すれば、その訳文は実際の製品のリリース版に含まれるようになります。

誤訳をしないために気をつける事

ただ、この時には淡々と機械的に訳していけば良いというわけではありません。翻訳対象の文字列はフレーズ単位で切り出されている事が多いため、同じ単語でも文脈によって適切な訳は変わってきます

例えば、本記事執筆時点では、カレンダーの予定の編集画面でリマインダーを詳細設定しようとすると、以下のような奇妙な選択肢があります。

(スクリーンショット:誤訳の例)

「秒」と「時間」の間に「最低」という語が登場しています。これは英語では「min」(minutesの略)となっていた部分で、正しい訳は「分」です。「min」を文脈を踏まえないまま「minimum(最低、最小)」の略と誤解してしてしまったために発生した誤訳という事になります。このような事が発生しないよう、特に短いフレーズを翻訳する時は、実際に運用中のownCloudの画面上でその文字列がどのように表示されているかをまず確認する必要があります。

とはいえ、短い文字列が画面内のどこに登場しているかを適切に探り当てるのはなかなか大変です。そこでヒントになるのが、訳文の入力欄の下にある「文脈」タブの中の「出現場所」という情報です。

(スクリーンショット:「文脈」タブ内の「出現場所」)

リソースによってはこのメタ情報が登録されている場合があり、これを参考にすると、原文が登場している部分の前後のソースコードを一緒に見る事ができます。この事例でもjs/app/controllers/valarmcontroller.js:61が出現位置であると記載されており、実際の当該箇所を見ると、前後の他の文字列やtimeUnitReminder〜という名前付けから、「これは最大・最小という文脈ではなく、リマインダーの時間の単位の文脈である」という事が読み取れます。

(スクリーンショット:「min」の出現場所)

文脈を確認しやすくするFirefoxアドオンの使用

いざ文脈を確認しようとした時に躓く点があります。それは、出現位置の情報はリンクになっておらず、Transifexのサイト上からは辿れないという事です。

筆者が調べた範囲では、これらの「出現位置」の情報はプロジェクト運営者がリソースを登録した時に任意の形式で付与するメタ情報で、Transifexのシステムには統合されていないようでした。そのため、対応するプロジェクトのソースコードがどこで公開されているかは自分で調べなくてはいけません。また、場所が分かったとしても、Transifexの画面を表示しているブラウザとソースコード管理ツールや端末の画面とを行ったり来たりする羽目になるため、作業効率が良いとは言い難いです。

そのため、翻訳の作業を補助するためのツールとして、Firefox用のアドオンを開発・公開することにしました。

Firefoxにこのアドオンをインストールすると、Transifexのページ上での右クリックメニューから「この翻訳の出現場所を開く」を選択するだけで、画面上に記載された「出現場所」の情報を自動的に検出し、GitHubなどのWebサイト上のリポジトリの対応する位置を新しいタブで開けるようになります。

(スクリーンショット:「Transifex Open Occurrences(出現場所を開く)」が追加したコンテキストメニュー項目)

Transifex上のプロジェクトとソースコードが公開されているWebサイトとの関連付けは、手動で作成したリストに基づいています。本記事執筆時点では、ownCloudチームの各プロジェクトのほとんどのリソースに対応する情報はあらかじめビルトインの情報として登録済みとなっています。

翻訳が難しいケース

前述のような単純な誤訳は元の語の出現位置を確認すれば防げますが、それだけではどうにもならない場面もあります。例えば、本記事執筆時点ではカレンダーの予定の編集画面でリマインダーの詳細設定と繰り返しの詳細設定において以下のような訳があります。

(スクリーンショット:同じ原文が異なる文脈で登場している例)

リマインダーの設定では「前」の次の選択肢が「回数で指定」となっていますが、これは原文ではそれぞれ「before」と「after」になっています。何故このような訳があてられているかというと、繰り返しの設定で終了条件を設定する箇所の「after N event(s)」の「after」に対して設定された「回数で指定」という訳文が使われているためです。

この問題は、多くのソフトウェアで表示文字列の国際化に使われている仕組みである「gettext」(およびそれを参考にした各言語のライブラリ)の仕様上の欠点に由来します。gettextは原文をそのままコードの中に埋め込んでおき、原文の文字列そのものをキーとして訳文を対応付けるという仕組みです。そのため、同じフレーズの原文が複数の異なる文脈で使われていると、訳文もそれに引きずられて、それぞれの文脈に対して同じ物しか設定できないのです。

このような問題が起こらないよう、gettext形式で国際化に対応するソフトウェアを作成する場合、可能な限り同じ原文が異なる文脈で使われないように気をつける必要があります。あるいは、gettext形式ではなく、同じ原文に対して異なるコンテキスト情報を持たせられる仕組み*4に切り替えるという方法もあります。この事例も本来的には、そのようにカレンダーアプリの実装を訂正することが望ましいと言えます。

翻訳コミュニティへの参加案内の後半を見ると、翻訳の元になる原文の側を変更したい場合は各プロジェクトのGitHub上のIssue Trackerに報告をするよう書かれていましたので、本件についても改善案を起票してみました。また、暫定的ではありますがそれぞれの場面に共用できる訳語として「後」をあてるように変更しました(これなら「前/後」「後 N 回」となるため、まだ違和感は少ないでしょう)。

用語を統一しよう

誤訳とは違った部分で気をつけなければいけない点として、「用語の統一」があります。同じ英単語に対して同じ文脈でも複数の訳語(同義語や、「ユーザ」と「ユーザー」のような音引きの有無の違いなど)がある場合、それぞれの箇所で異なる訳語を選んでしまうと混乱の元になります。

Transifexの翻訳画面で訳語の入力欄の下の「用語集」タブを選択すると、原文に含まれている各単語について、これまでに登録された訳語を一覧表示することができます。

(スクリーンショット:用語集を表示した所)

原文に用語集に登録された単語が含まれていて、訳文でそれに対応する訳語が使われていないと、編集画面上に警告が表示されます。その場合は用語集を確認して、なるべくそこに載っている訳語を使うようにしましょう。また、新登場の語句に新しい訳語を割り当てた場合には、その情報を用語集に忘れずに登録しておきましょう。

とはいえ、このシステムも完璧ではないため、杓子定規に警告が表示されてしまう場合もあります。例えば「Delete」という単語について「削除する」という訳語が割り当てられている時に、ボタンのラベル文字列としては一般的には「削除」と体言止めする事が多いですが、そのように訳すと「削除する」という訳語が含まれていないと判定されてしまう場合があります。

(スクリーンショット:訳語の警告が出ている様子)

また、原文で「event(s)」のように単数形か複数形かあやふやな物を括弧書きで表している場合に、訳語を「回」とあてると「括弧書きに対応する部分が無い」という警告がなされる場合もあります。文脈によっては直訳ではなく意訳した方が分かりやすく自然になる場合もあるという事も考慮すると、警告の内容が常に絶対的に正しいとは限りませんので、あくまで意図しないミスを防ぐための目安として考えるに留めると良いでしょう。

レビューする側にも回ってみよう

前述の通り、Transifexでの翻訳は訳文を保存した段階では「レビュー待ち」の状態となっており、レビュー権限がある人のチェックを経て初めて実際のリリースに反映されます。

今回翻訳に参加してみた所、ownCloudの日本語の翻訳については未訳の項目はそれほど多くなく、それよりも「レビュー待ち」の項目が数百件といった単位で存在している事が目につきました。これはレビューが滞っている事の顕れで、このままではどれだけ翻訳を行ってもその結果が実際のリリースに反映されないという事になってしまいます。このような場合には、レビューする側として協力することで、プロジェクトを円滑に進める手助けができるはずです。

筆者はFirefoxの翻訳コミュニティの活動の情報も耳に入れていた事から、上記のような翻訳の注意点についてはいくつか知見がありましたので、レビュアーに立候補してみる事にしました。

先のownCloud公式サイト内の翻訳協力者向けの情報を見ると、Transifexではユーザーの権限にいくつかの種類がある事と、議論はownCloud Centralというフォーラムで行うよう案内されていました。そこで、翻訳のレビュアーの自薦や他薦を受け付けているスレッドはないかと思い「translate」という単語でフォーラム内を検索してみましたが、残念ながらそれに特化した既存のスレッドはすぐには見付けられませんでした。

何か他の手段で連絡を取れないものかと思い関連情報を辿っていったところ、上記のページからリンクされているTransifexのサイト内でのユーザーの各権限の説明に、「管理者またはコーディネーターの権限がある人は、ユーザーの権限の変更とレビューができる」という旨の情報が書かれていました。そこで日本語の翻訳に関わっているユーザーの一覧を見てみたところ、「レビュアー」は一人もおらず「コーディネーター」は日本語話者らしき方が一人だけいるという状況でした。そこで、唯一のコーディネーターであった方宛にTransifex上で以下のメッセージを送ってみました。

Subject: コーディネーター/レビュワーへの自薦

はじめまして。Piro / 結城洋志と申します。
大変不躾な申し出で恐縮ですが、ownCloudプロジェクトの日本語の翻訳についてレビュー権限のあるロールを与えていただく事はできませんでしょうか?
最近自社でownCloudの運用を始めた際に、Calendarに一部未訳箇所があったことに気付き、翻訳に参加しようと思い立ちました。
その際、現在レビュー待ちの翻訳が非常に多い状態であることと、日本語のレビューを行える方がguitarmasakiさんお一人であるらしい事に気がつきました。
自分はownCloudの翻訳には今日からようやく関わり始めた状況で、貢献量もまだまだ微々たる物ではあります。
しかしながら、個人的にMozilla Firefoxの(主にアドオンの)コミュニティで長く活動しており、GUIの翻訳についてはある程度の知見があると自負しております。
ですので、ownCloudの日本語翻訳レビューにも貢献が可能ではないかと考えております。
以上、急な申し出で失礼かとは存じますが、ご検討いただけましたら幸いです。
https://docs.transifex.com/teams/understanding-user-roles/ を見たところ、言語ごとのユーザーのロール変更はコーディネーターの方に権限があるとの事でしたので、メッセージにて連絡させていただいております。)

すると早速お返事を頂けて、ロールをコーディネーターに引き上げていただく事ができました。

これで未レビュー部分のレビューを進める事ができるようになります。レビュー権限がある状態で翻訳画面を開くと、訳文の保存ボタンの横に「レビュー」というボタンが表示されるようになります。

(スクリーンショット:レビュー権限があるユーザーで翻訳画面を見た様子)

前述の翻訳上の注意点をチェックした上で「レビュー」ボタンを押せば、その翻訳はレビュー済みとなります。

まとめ

以上、Transifexを使ったownCloudの翻訳作業の進め方と、レビュー権限を得るための自薦の様子をご紹介しました。

今回の一連の過程でレビュー権限を得る事となりましたが、依然としてアクティブなレビュアーの数が少ない状態である事には変わりありません。そのため、独断ながらリスクヘッジとして、個人的に信頼のおける方や推薦を頂いた方など何名かのロールをレビュアーに引き上げさせていただきました。
現在はこの体制で未レビュー分の消化を進めていますが、文脈の正しさの検証をきちんとやるにはそれなりの手間がかかるため、すべて消化するまでにはまだまだ時間がかかりそうです。
レビュアー側として翻訳に協力できると自負される方がいらっしゃいましたら、Transifex上のpiroorユーザー宛に自薦のご連絡を頂けましたら幸いです。

また、Transifex上での翻訳作業を補助するFirefox用アドオンのTransifex Open Occurrences(出現場所を開く)は、ownCloud以外のプロジェクトで広く利用できる設計となっていますが、他のプロジェクトで使うためには「出現場所」を実際のリポジトリに紐付けるための情報を手動で登録する必要があります。
他のプロジェクトでお使いになる場合には、次の参加者の方が手動での登録をしなくても良くなるように、ビルトインの情報の追加手順を参考にしてマージリクエスト(GitHubでいうプルリクエスト)をお送り下さい。

「OSS開発に参加する」というと何か特別な事のように思う人もいるかも知れませんが、基本的には

  • ownCloudのカレンダーに未訳のままの箇所があるという問題があるので、翻訳に協力して解決する。
  • 翻訳に使うWebサービスが使いにくいという問題があるので、補助するツールを開発して解決する。
  • 翻訳のレビューが滞っていてせっかくの翻訳が反映されにくくなっているという問題があるので、レビュー作業に協力して解決する。

といった要領で、何か問題に遭遇した時にそこで諦めず、その時に自分の取れる手段で問題の解決を図っているという事になります。
皆さんも普段使っているソフトウェアやサービスで「ここ、何かおかしいな」「どうにかならないのかな」と感じた時には、ぜひとも、そのソフトウェアやサービスに適切にフィードバックする方法を調べて、問題を見過ごさずに解決を図るようにしてみて下さい。
(実際にフィードバックしてみるのは不安があるという方は、OSS Gateのワークショップへの参加も検討してみて下さい。)

*1 自由なライセンスの実装を積極的に使っていかないと、品質が改善されず、いつまでも「使い物にならない」状態が放置され、フリーソフトウェアがますます使われないという事になってしまうからです。

*2 Transifexのサイト上でアカウントを作成する以外にも、GitHubやGoogle、Twitterなどの認証情報を参照する形でもアカウントを作成できます。

*3 「_JP」の部分が地域を表しています。これは、「イギリス英語(en_GB)」と「アメリカ英語(en_US)」のように、分類上は同じ言語でも地域によって単語の綴りや使われ方に差異がある場合を想定した表記です。

*4 C言語やDjangoではpgettext、Rubyではp_()などがそれにあたります。

2018-11-16

タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|