KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

枯れた技術で当社比15倍の性能を達成したWindowsアプリを作った話

カケハシのプラットフォームチームで開発ディレクターをしている髙橋です。
本職はアジャイルコーチやスクラムマスターなのですが、社内事情により2024年3月ごろから期間限定で実装をメインに活動しております。
今現在、UIと言えばスマートデバイスを含めたクロスプラットフォーム開発やWebアプリの開発が主流かと思いますが、今回は2002年にリリースされ未だ現役でありながらほとんど進化をしていないWindowsフォームでアプリケーションを作った時のお話をさせていただきます。
なぜ枯れた技術でアプリを作ったのか、その際どんなことに気を付けたのか、といった点について私見を述べさせていただきます。

この記事は秋の技術特集 2024の 9 記事目です。

執筆するに至った背景

この特集の担当者から依頼があったというのが本音ではありますが、今回の件を通して所謂「富豪的プログラミング」だったり、ものづくりの過程で気を配らなければいけないことについて改めて伝えていく必要があると感じました。(富豪的プログラミングとは、雑に言うとCPU、メモリ、ディスクサイズなど気にせず、とにかくUXや動くことを優先して開発することです)

現在のCPU、メモリ、ストレージは高速で大容量化し、クラウドにおいてはお金さえかければ無限に近いリソースを得ることが出来ます。そのため、演算処理による負荷を意識せず開発が可能で、仮に問題が起きてもほとんどの場合お金で解決することが出来ます。(とはいえどうにもならないこともありますが)
開発コスト(人件費)、追加で支払うクラウドやソフトウェアなどの費用、市場への投入速度や時期などを鑑みると、その時の状況によって正解が異なるため、一概に何が正しいとは言えません。 また、コーディングの際にほとんどの面倒な処理はライブラリやフレームワークが解決してくれるため、より一層プログラミング的な難しさよりも現実の問題を解決することにフォーカスしていってます。(それが悪い、と言うつもりはありません)
しかしながら、それが当たり前になったが故に、物の仕組みや原理原則を理解することよりも使い方を覚える機会ばかりで、いざ仕組みを作る側に回った時の難しさだったり、ランニングコストや利益に対する意識の低下、ということが起こりうるのではないかと危惧しています。

爪に火を灯すようにアルゴリズムなどを突き詰めることは今の時代にとって不要であることは十分理解しています。その一方で、制約がある中で突き詰めていく楽しさだったり(例えば競技プログラミング)、その過程の中で培われるスキルは決して無駄ではないと考えるため、老婆心ながら誰かの何かのきっかけになれば良いなと思います。

本件の背景(ここから本題)

ビジネス的な事情を多分に含むため、多くは語れませんが、とある理由で同時期に2つのシステムを開発する必要があり、両者共にクライアントはWindowsOS上で動くデスクトップアプリ、バックエンドはAWSという構成でした。
非機能要求としては、対象とするOSのバージョンはマイクロソフトのサポート期間内で最も古いもの、それに伴いマシンスペックは発売当時に主流だったもの(例えばメモリは4~8GB)というものがありました。
さらに他社のアプリとも共存しなければいけない、という事情があり、後発組としては極力リソースの消費を抑える必要がありました。

最初はプロトタイピング

Windowsアプリの開発について社内に知見がある者がほとんどおらず、皆がイメージしにくい状況でしたのでまずはプロトタイピングをしました。
経験と具体的なイメージを持っていたのが私だけだったため、早期のフィードバック収集と皆のイメージを固め方向性を明らかにすることを目的としています。

このタイミングで御大層なアーキテクチャや完璧な設計に時間をかけることは、機会損失のリスクがあります。
しかしながら、リスク回避のためのプロトタイピングがいくつかのことに注意しないと逆にリスクになりかねません。(上手くやれないならウォーターフォール的にやったほうがマシということ)
例えば、この時の成果物をそのまま流用すると長期的な視点に欠けているため、将来的にスケーラビリティや保守性の問題が生じることがあります。
また、コードについては多くの場合、技術負債の温床となります。
そのため、基本的には捨てる、または大幅に作り直す、という前提と覚悟で作成します。
しかしながら、もったいない、と思ってしまうのが人間の性です。
時間をかけて沢山の物を生み出すと人間は惜しいと感じるため、なるべく時間をかけない、たくさん作りすぎない(つまり捨てやすい)というのが心理的ハードルを下げる事につながるでしょう。
そのためには、プロトタイピングの目的をしっかり持って要点を絞って作成するのが大切です。
一応補足しておきますが、不確実性が無く要件も明確な場合は逆にプロトタイピングをしない方が良い場合がありますのでケースバイケースであることを留意しておいてください。

最初の技術選定

ハードウェアリソースへのアクセスやOSのAPIを利用する必要がある、クライアントOSの多くがWindows10でアップデートもきちんと行われていない可能性が高い、という前提があったため、社内で主流の開発言語であるPython及びTypescriptとC#を比較したときに、後者のほうが安全で安心だろう、とステークホルダーたちと合意しました。
ターゲットフレームワークについては、OSの制約から現在主流の.NETではなく、.NET Frameworkのほうが確実で、尚且つサポート期限に余裕がありつつ古いものが良いだろうということで.NET Framework 4.7.2としました。
UIについては、自由度と再利用性の高さから WPF(Windows Presentation Foundation) + MVVM(Model View ViewModel)パターンで作ることにしました。

WPFのメモリ問題

一旦プロトタイプが完成し、フィードバックも上々だったものの、ひとつ問題が有りました。
とりあえず満足のいく見た目や動きであったものの、実行時のメモリが300M前後で推移してました。
尚、UIもロジックも組み込んでいない空のウインドウ状態を起動しただけでも280M程度消費します。
今現在のハードウェアスペックでは大きな消費量には見えないかもしれません。
しかしながら、メモリが4GBのPCでは7.5%、8GBのPCでは3.75%に相当し、決して少なくありません。
とはいえ、大丈夫そうじゃないか?と思われる方もいらっしゃるかもしれませんが、メモリ使用率には余裕を持っておいたほうが安定してパフォーマンスを発揮します。
例えば、OSはメモリを使ってキャッシュをすることで処理を高速化していたりします。その為、空きに余裕があったほうがキャッシュの利用効率が高いです。
また、メモリが不足してくると、ストレージに一時的にデータを保存します。(これを仮想メモリと呼びます)ストレージはメモリと比べると圧倒的に遅く、これが頻繁に行われると著しくパフォーマンスが落ちます。
何らかのソフトウェアが瞬間的にメモリを大きく消費することもあるため、まだ空きがあるから、と安心せずに余裕を空き容量を確保しておいたほうが良いでしょう。

ようやくタイトル回収

ということで、プロダクションコードはWPFからWindowsフォームへ変更することにしました。
MVVMで作っていたこともあり、VMとMについてはそのまま使えました。(実のところ最初から意識してましたが)
ご存じの方もいらっしゃるかとは思いますが、Windowsフォームには一応DataBindingはあるものの、WPFのように使い勝手はよくありません。(今もアップデートされ続けていますが基本的に互換性の維持だけなので進化がほとんど無い)
そのため、移植するにあたって、ViewとViewModelのつなぎ役を作成しました。車輪の再発明のようで無駄に感じるかもしれませんが、これには理由があります。
.NET Frameworkは4.8でメジャーアップデートが終了です。もちろんランタイム自体はまだ数年サポートされることが予想されますが、そう遠くない将来、.NET Frameworkから.NET(紛らわしい)へ移植するのは確実です。その際に、なるべく影響を少なくしたいと考えました。
よって、ViewModelをWindowsフォームの都合に合わせて改修するよりも繋ぎ役を作って、将来に備えることにしました。
Agile的には若干アンチパターンのような気もするのですが、確実に起こる未来である為、今回の選択となります。(未来の担当者に負債を残さない)
また、Windowsフォームはちょっとしたツールを作るのにはすごく手軽で便利なのですが、最近のUIしか知らない人からするとコントロールのプロパティを編集するとか意味がわからないし気持ち悪いし、そんなコードは可読性も悪いです。
未来永劫、私がメンテナンスするわけにもいきませんし、ナレッジトランスファーをしていかなければいけない為、第三者がなるべく容易に理解できるように、という配慮も込められています。

繋ぎ役のサンプル

値を表示する系のコントロールは使い勝手は悪いもののDataBindingは用意されていますが、このバージョンのWindowsフォームで一番困るのがButtonとICommandのBindingです。(.NET7から可能になりました)
Binding出来ないため、コードビハインドにクリックイベントの処理を書いてその中で直に呼び出すしかないのですが、コードビハインドには書きたくないし、ベタに書いてしまうとコピペっぽいコードだらけでダサいし、静的解析ツールに引っかかるし、となるので以下のようなヘルパークラスに以下のメソッドを用意しています。(読みやすくするために最低限だけ抜粋してます)

public static void BindCommandToButton(Button button, ICommand command)
{
    // コマンドの実行
    button.Click += async (sender, e) =>
    {
        if (!command.CanExecute(null)) return;
        button.Enabled = false;
        await Task.Run(() =>
        {
            command.Execute(null);
        });
    };
    button.Enabled = command.CanExecute(null);

    // ボタンの活性・非活性をViewModelで制御できるように
    command.CanExecuteChanged += (sender, e) =>
    {
        if (!button.Parent.IsHandleCreated) return;
        button.Parent.Invoke(new Action(() => { button.Enabled = command.CanExecute(null); }));
    };
}

これを以下のように使うことで簡潔に記述することが出来ます。

DataBindingHelper.BindCommandToButton(loginButton, viewModel.LoginCommand);

こういった工夫の積み重ねで枯れた技術も今風な使い方が出来ます。

その他、リソース消費を抑えるテクニック

冒頭で「富豪的プログラミング」に触れてしまったので、リソース消費に関するサンプルも少し載せておきます。

その1 正規表現

みんな大好き正規表現ですが、実はパフォーマンスに大きな差が出ることがあります。
正規表現は手軽で可読性も良いため、私自身も好んで使いますが、実は効率が良いとは限りません。
このような比較用のコードを用意しました。

static void Main(string[] args)
{
    string input = "Hello, World!";
    Stopwatch sw = new Stopwatch();

    sw.Start();
    for (int i = 0; i < 1000000; i++)
    {
        Regex.IsMatch(input, "[o]");
    }
    sw.Stop();

    Console.WriteLine($"Regex: {sw.ElapsedMilliseconds} ms");

    sw.Reset();
  
    sw.Start();
    for (int i = 0; i < 1000000; i++)
    {
        for (int j = 0; j < input.Length; j++)
        {
            if (input[j] == 'o')
            {
                break;
            }
        }
    }
    sw.Stop();

    Console.WriteLine($"for loop: {sw.ElapsedMilliseconds} ms");
}

私の環境では
Regex: 265 ms
for loop: 21 ms
という結果になりました。(12.6倍)
経験上、単純なパターンほど結果の差が顕著に表れやすいです。(この位の例であれば、実際にコードに書いて実行しなくても頭の中でぐるぐる回してみれば想像つくかと思います)
尚、C#の場合、静的メソッド以外にも以下のようにインスタンスを生成して使いまわすことが出来ます。

var regex = new Regex("[o]", RegexOptions.Compiled);
for (int i = 0; i < 1000000; i++)
 {
    regex.IsMatch(input);
}

コンパイル済みの為、静的メソッドと比べたときに実行時間は100ms程度まで下がりましたが、それでもなおfor loopの方が5倍近く速いです。

処理時間が短い、ということはCPUを占有している時間が少ない、ということになります。
占有している時間が短ければ短いほど、他のプロセスやタスクにCPUを割り当てられるため、全体的なパフォーマンスが向上します。

正規表現以外でも可読性を犠牲にして処理効率を高められることもありますので、盲目的に慣習などに囚われずにコードを書いていただきたいなと思います。(あくまで必要に応じて)

その2 テキストファイルの処理

次に、よくあるファイル処理を比較してみます。

// 一気に読み込むパターン
static void Main()
{
    string filePath = "一行が長くて行数が多いテキストファイル.txt";

    // ファイル全体をメモリにロード
    string fileContent = File.ReadAllText(filePath);

    // ファイル内容を処理
    Console.WriteLine("ファイルの文字数: " + fileContent.Length);
}
// 1行ずつ読み込むパターン
static void Main()
{
    string filePath = "一行が長くて行数が多いテキストファイル.txt";

    // ファイルを1行ずつ読み込む
    using (StreamReader reader = new StreamReader(filePath))
    {
        string line;
        int lineCount = 0;

        while ((line = reader.ReadLine()) != null)
        {
            // 各行を処理
            lineCount++;
        }

        Console.WriteLine("総行数: " + lineCount);
    }

両者を比較したときどちらが効率が良いと思いますか?
実は、用途次第でどちらでも良いのです。
メモリ使用量については後者の方が最小限に抑えられます。
しかしながら、性能の観点では前者の方が効率が良い場合があります。
理由は、ファイルアクセスの回数です。
全行読み込んだメモリから1行ずつ処理するのに対して、1行ごとにファイルを読み込むということは毎回ディスクI/Oが発生し、ディスクI/Oはメモリに比べ遅いです。
その為、取り扱うファイルの1行当たりのサイズと行数、実行環境のリソース状況を鑑みてどちらの処理を選ぶかは決めてください。
尚、1行ずつ読み込む場合は、非同期で読み込むと、ディスクI/Oの待ち時間を有効に活用できるため、応答性が向上することがあります。

最終的なパフォーマンス

最終的にCPU使用率は起動時などのピーク時に10%未満で常時ほぼ0に近く、メモリ使用率はWPFの10分の1以下になりました。
所謂「業務システム」ということもあり、コンシューマー向けの見栄えや、派手なアクションなども不要なため、枯れた技術でも十分に要求に応えることが出来ました。

まとめ

本記事では、コーディングのテクニックなどとはまた違った「エンジニアリングに必要な考え方や進め方」について述べさせていただきました。
現代では不要となりつつあることも多数書いてしまいましたが、もしかしたらどこかで役に立つかもしれません。
ここまで読んでいいただき、ありがとうございます。文章が長く、纏まりが欠けていたかもしれませんが、お許しください。