Qbs でRuleやProductを自作する方法(ざっくり解説)


ご挨拶

Qt Advent Calendar 2018にトップバッターで参加することにしたKATO Kanryuです。
よろしくお願いします。

まず宣伝

  • QLanguageSelector
    • 言語切り替えUI(メニュー)を自動生成
    • Qtアプリを再ビルドなしに翻訳言語を増やせるようになります
    • Qtアプリをテキストエディタだけでリアルタイムに翻訳できるようにします(本稿)
  • 世界最速の画像ビューアー、QuickViewerをQtで作りました。
  • QActionManager
    • アプリにQt Creatorと同等のキーボード/マウスショートカットのカスタマイズ機能を提供します。マウスカスタマイズ機能はオリジナル
  • QFullscreenFrame
    • アプリをフルスクリーン表示させたときに、メインメニューやツールバーをスライド表示したいときがあると思います。それを実現するやつです。イベントハンドラを細かく設定することになるのでC++11推奨
  • QNamedPipe
    • アプリケーションを複数起動防止しつつ、2つ目以降のプロセスの起動オプションを1つ目のプロセスに引き渡したりする処理って単純ながら実装が面倒なものです。この問題を各OSのNamedPipeを使ってシンプルに解決するライブラリです。
    • 本家のQNamedPipeと異なり、QNetworkなどのコンポーネントは不要です。

そもそも Qbs とは

公式の記事をどうぞ。
https://blog.qt.io/jp/2012/03/03/introducing-qbs/

要するにQMakeに代わってQMLの構文でビルド作業をできるようにしたやつです。

テキストベースで翻訳するというニーズ

実はQuickViewerが海外的な知名度な割になかなか翻訳言語が増えないのが最近の悩みなのです。以前プロの翻訳家に翻訳作業を依頼したとき、ts形式で翻訳するためのQt Linguistを使用できない環境があるという事実が判明しまして、(何しろiPadで翻訳していた!)その時は急遽プレーンテキストな翻訳フォーマットをでっち上げてそれベースで作業していただきました。

Note: ts形式というのは例を挙げるとこのようなXMLファイルです。人力で修正するのは無理そうですね。
https://github.com/kanryu/quickviewer/blob/master/QuickViewer/translations/quickviewer_ja.ts

そういう経緯もあってWindowsで著名なフリーソフトのファイル構成を調べてみたんですが、結構 *.lang であったり *.lng であったりとテキスト形式の翻訳ファイルでアプリケーションの翻訳を実現しているんですね。ところがQt文化圏では実装例がなかなか見つからない。

それならば、ちょうどQLanguageSelectorという似たようなものを公開済みなので、それに追加してしまおうじゃないか、ということになりました。

Qbsに挑戦した理由

すると tsフォーマットからiniファイルに変換する必要があるわけですが、コンバーターを普通にC++で書いても他の開発者にとって使いづらくないでしょうか。ビルドに必要な道具を先にビルドしないと全体がビルドできないソースとかちょっと嫌ですよね。

そういえば、QtにはQbsというビルドツールが標準で付属しているわけで、これでいけそうな感じがしたので実装することにしました。Qbsで実装すれば、Qbsをビルドツールに採用しているプロジェクトならそのまま呼び出せるし、Qmakeベースのプロジェクトでもqbsコマンドを叩くだけで動かせるので都合が良いわけです。

ところが……。

Deprecation of Qbs
http://blog.qt.io/blog/2018/10/29/deprecation-of-qbs/

なんでやねん。

そろそろ本題。Qbsの仕組み

通常、QbsではC++の実行ファイルや共有ライブラリをビルドする内容を記述するわけですが、Qbs自体は汎用言語なので他のことも自動化することができるはずです。ところが……ドキュメントがない。
一応公式のマニュアルはこれなんですが、ないよりはマシという程度。これだけじゃQbsの書き方は全然わからないですね。
https://doc.qt.io/qbs/

なのでわからないなりにかいつまんで説明しますと、
Qbsとはこういう仕組みで動いています。

  • 構文はQML
  • 出発地点はProject。Project内に必要なだけProductを記述できる。
  • Productから新しいProductを生成する
  • 処理方法についてはRuleに記述する
  • Productから別のProductを生成するRuleを指定するには以下を指定して関連付ける
    • 入力ファイルとなるfilesプロパティ
    • 入力ファイルの型に相当するタグ。出力側fileTagsプロパティと入力側のinputFromDependenciesプロパティまたはtypeプロパティで一致させる
    • Productを生成するのに必要な依存プロダクトおよびRuleを指定するDependsタグ
  • RuleはModule内に記述しても良い。DependsタグにModule名を書くと自動的に参照される

Makefileとの違い

なんだかよくわかりませんので、昔から使われてきたMakefileと比較してみましょう。
Makefileは基本、構文は単純です。

生成したいファイル名1: ソースファイル名群1
    処理

生成したいファイル名2: 生成したいファイル名1 ソースファイル名群2
    処理

という感じで、単純なケースなら依存性解決をしてくれるシェルスクリプトみたいなノリで書けます。

Note: この例の場合、『生成したいファイル名1』は初期状態で存在しないので『生成したいファイル名2』を生成する前提条件が達成できず、依存性解決の結果『生成したいファイル名1』が先に生成され、次に『生成したいファイル名2』が生成されることになる

Qbsのとっつきが悪いのは、Makefileでは自明である

  • 生成したいファイルの指定
  • 生成したいファイルを作るのに必要なソースファイルの指定
  • 生成するのに必要な処理

が分離しているところじゃないかと思ってます。

Ruleについて

これまでの説明でRuleとは『Productから別のProductを生成するのに必要な実際の処理』と書いたわけですが、構文的にはこんな感じです。これは実際のサンプルを見たほうが早いでしょう。
https://github.com/qbs/qbs/blob/master/tests/auto/blackbox/testdata/rule-with-no-inputs/rule-with-no-inputs.qbs

Rule {
  inputs: []
  Artifact {
    filePath: "output.out"
    fileTags: ["output"]
  }
  prepare: {
     console.info("running the rule: " + product.dummy);
     var cmd = new JavaScriptCommand();
     cmd.description = "creating output";
     cmd.sourceCode = function() {
         console.info(product.version);
         var f = new TextFile(output.filePath, TextFile.WriteOnly);
         f.close();
     }
     return [cmd];
  }
}

Artifactタグが必須で、出力するファイル名とタグを指定します。
prepareプロパティ内に処理内容をJavaScriptで記述します。

ここで、サンプルの実装例が奇妙な書き方をしているのに気づきますね。

Ruleの実際の処理内容はJavaScriptCommandに書く

prepareの内容は、実際の処理内容をJavaScriptCommandクラスのインスタンスのsourceCodeプロパティに関数として記述して代入し、prepareの処理結果としてはJavaScriptCommandクラスのインスタンスを返すだけです。なぜこのような面倒な記述をしているのかというと、JavaScriptの世界では基本的にマルチスレッドが存在しないので、個別のビルド処理をJavaScriptCommand化することで、このクラスのインスタンス単位で複数のビルド処理の並列化を実現しているためです。

最初、Productだけで処理を書いていて、実際それはある程度動いたのですが、なぜか記述したProductの各プロパティが4回ずつ参照され、巡回参照エラーで落ちるという挙動が治らなくて諦めた経緯があります。そもそもQbsが提供しているService(要はライブラリ)はJavaScriptCommandまたはProbeのスコープでなければ動かないものが結構あるので、そういうものだと諦めたほうがいいです。

Note: 今なら原因はわかります。Product同士の依存関係を設定していなかったため、始点と終点のProductが同一になり、自分自身でループしていました。

再びRule。今度はちゃんと入力ファイルを受け取れるものを

再び実装例を。まず1つ目がRuleの実装例で、
https://github.com/kanryu/qlanguageselector/blob/master/modules/transconf/transconf.qbs
2つ目がRuleの呼び出し例です。
https://github.com/kanryu/quickviewer/blob/master/QuickViewer/translations/maketransconf.qbs

1つ目はQLanguageSelectorで実際に使われているRuleです。
コードが長いですが、一番下のRuleを見てください。転載してみます。

Rule {
    inputsFromDependencies: ["ts_reverse_input"]
    multiplex: true
    Artifact {
        filePath: product.reverseConfName + product.confExt
        fileTags: ["Reverse translation"]
    }
    prepare: {
        var cmd = new JavaScriptCommand();
        cmd.description = "transconf: " + input.fileName + " -> " + output.fileName;
        cmd.sourceCode = function() {
            var header = product.template;
            var option = {
                method: "reverse",
                header: header,
                entries: header.entries && header.entries[input.fileName]
                          ? header.entries[input.fileName] : undefined,
            };
            var tsroot = Main.parseTranslationXml(input.filePath);
            Main.generateTranslationIni(output.filePath, tsroot, option);
        }
        return [cmd];
    }
}

inputsFromDependenciesプロパティはこのRuleが受け取ることができるタグで、ソース側のProduct.Group.fileTagsプロパティに対応しています。

Product {
    name: "Make the reverse translation"
    Group {
        files: ["quickviewer_ja.ts"]
        fileTags: ["ts_reverse_input"]
    }
}

つまり、この例でRuleはProduct.Group.fileTagsプロパティで"ts_reverse_input"と指定されたfiles: ["quickviewer_ja.ts"]を入力ファイルとして受け取るわけです。

なお、各入力ファイルはQbsにより自動的にファイルのフルパスが補完されます。inputは単なるStringではなくオブジェクトになっているので、ファイル名や拡張子、ファイルがあるフォルダパスなど、フルパス内のパーツ部分をプロパティ参照で見られます。

出力ファイル名はArtifact.filePathプロパティで指定します。QMLでは各プロパティの生成をJavaScript化することができます。ノリとしてはC#のプロパティのgetterみたいな感じです。式で書くときはそのまま。コードとして書くときは{ }の中にコードを記述し、最後の行にreturn文で戻り値として返せばよいです。要するにfunction(project, product, input) の中身を書いているつもりで記述すればOKです。

Note: Ruleは暗黙的にproject, product, inputのインスタンスが渡されているという想定で記述します。

sourceCode内の処理は、実質的な部分をMainオブジェクトのメソッドに移譲しています。Mainオブジェクトは単なるJavaScriptファイルで、Moduleと同じディレクトリに置いておけば

import "./main.js" as Main

という構文で利用できます。JavaScriptにおけるimport構文はqbs内と若干異なるので注意してください。

Productのモジュール化

さて、これでRuleができたので後は生成元、生成先のProductをそれぞれProject内に記述すればビルド処理として実現できるのですが、今回の処理はある程度カスタマイズできるようにするために前提条件としての情報が色々とほしいので、単なるProductでは冗長です。共通化してしまいましょう。このファイルを見てください。
https://github.com/kanryu/qlanguageselector/blob/master/modules/transconf/TransConfProduct.qbs

Note: Ruleは処理中のProductのプロパティをproduct.propertyName でアクセスできるので、処理のカスタマイズ機能をProduct側に外出ししようとしている)

このソースファイルに、TransConfProductとなる予定のProductの定義があります。
最終的な呼び出し元となるmaketransconf.qbsで、

import "../src/qlanguageselector/modules/transconf/TransConfProduct.qbs" as TransConfProduct

と参照することで、TransConfProductとして使われることになります。
どうなんでしょう、このQMLの流儀。私はやや分かりにくいんじゃないかなという気がします。

TransConfProduct.qbsでは各プロパティおよびそのデフォルト値、そしてinstall処理について雛形を記述し、呼び出し元であるmaketransconf.qbsで簡潔に書けるように配慮しておきます。

ここで

Depends { name: "transconf" }

と書くことによって、TransConfProductがtransconfモジュールおよびその中のRuleを有効化することを宣言していることに注意してください。

Note: QbsはModuleの検索時、"[qbsSearchPaths]/modules/[ModuleName]/[ModuleName].qbs"を探しに行く仕組みになっています。

maketransconf.qbsでは単なるProductタグの代わりにTransConfProductタグを使用することで、TransConfProduct.qbsで定義した内容を引き継げます。(オブジェクト指向的に言えばTransConfProductクラスのインスタンス化)

Product -> Product の依存性の記述(完成版)

では最後に呼び出し元であるmaketransconf.qbsでどう記述しているのか見ておきましょう。URL再掲。
https://github.com/kanryu/quickviewer/blob/master/QuickViewer/translations/maketransconf.qbs

Project {
    Product {
        name: "Make the reverse translation"
        Group {
            files: ["quickviewer_ja.ts"]
            fileTags: ["ts_reverse_input"]
        }
    }
    TransConfProduct {
        name: "Generating Reverse TransConf"
        type: ["Reverse translation"]
        version: project.version
        confExt: project.confExt
        template: {
            return {
                file_header: [
                    "; The reverse language file was generated at "+transconf.Generator.toMailDateString(new Date(Date.now())),
                    "; === COMMENTS ===",
                    "; DO NOT EDIT THE FILE",
                    "; ",
                ],
            }
        }
        Depends { name: "Make the reverse translation" }
    }
}

TransConfProduct(name: Generating Reverse TransConf")はProduct(name: "Make the reverse translation")を生成元とし、["quickviewer_ja.ts"]を入力ファイルとして"Reverse translation"を実現します。

終わり。