taki

C#でリモートのMySQLやSSH接続をやってみた


こんにちは、大阪スタジオ ソフトウェアエンジニアの滝です。
今回のお話は、C#でMySQLに接続してデータを取得したり、SSH接続でコマンドの送信をやってみたお話になります。

きっかけは、業務で集計ツールを作成する機会があり、その要件は、Windows環境からターミナルを使わずに、データベース及びログサーバーからデータ集計を行い、その結果をExcelファイルに出力するというものです。
実現には様々な方法が考えられますが、今回はWindows環境から行いたいということだったため、デスクトップクライアントを作成し、アプリケーションを実行することで集計処理を自動で行ない、結果をExcelファイルに出力するようなツールを作成することにしました。
要件を満たすために、C#でMySQLに接続してデータを取得したり、SSH接続でコマンドの送信を行う必要があり、どのように実現したかを簡単なサンプルコードと共に紹介させていただきます。

プロジェクトはコンソールアプリでもフォームアプリでもどちらでも動作します。
対応プラットフォームなど、詳しい情報は以下のリンクから確認できます。

 

今回使うライブラリ

・SSH.NET (github)
https://github.com/sshnet/SSH.NET

・MySQL(github)
https://dev.mysql.com/doc/connector-net/en/

 

SSH接続のやりかた

  • 必要なもの
    • SSH接続が可能なリモート環境(物理マシンでもVMでもOK)
      • 記事内で使用している環境はLinux CentOS 6.9(VirtualBox使用)
    • VisualStudio(どのエディションでも可)
      • 記事内で使用している開発環境はVisualStudio2017
  1. 新しいプロジェクトを作成または既存のプロジェクトを読み込む
    このサンプルではConnectSSHというプロジェクトをコンソールプロジェクトで作成しました。
    既存のプロジェクトでテストしていただいても問題ありませんが、できれば新規作成をおすすめします。

  2. NuGetからSSH.NETの追加
    図のように、ソリューションエクスプローラー内で1で作成したプロジェクト名の項目を右クリックし、NuGetパッケージの管理をクリックします。参照タブをクリックし、検索フォームに「SSH.NET」と入力し検索します。Renci製のSSH.NETをプロジェクトにインストールします

  3. とりあえず接続してみる
    以下にサンプルコードを記載します。コードの説明はコメントに書いております。

    using System;
    using Renci.SshNet;
    
    namespace RemoteServerConnect
    {
        class Program
        {
            static void Main(string[] args)
            {
                try
                {
                    // 接続先のホスト名またはIPアドレス
                    var hostNameOrIpAddr = "192.168.xxx.xxx";
    
                    // 接続先のポート番号
                    var portNo = 22;
    
                    // ログインユーザー名
                    var userName = "taki";
    
                    // ログインパスワード
                    var passWord = "xxxxxxxxxx";
    
                    // コネクション情報
                    ConnectionInfo info = new ConnectionInfo(hostNameOrIpAddr, portNo, userName,
                        new AuthenticationMethod[] {
                            new PasswordAuthenticationMethod(userName, passWord)
                            /* PrivateKeyAuthenticationMethod("キーの場所")を指定することでssh-key認証にも対応しています */
                        }
                    );
    
                    // クライアント作成
                    SshClient ssh = new SshClient(info);
    
                    // 接続開始
                    ssh.Connect();
    
                    if(ssh.IsConnected)
                    {
                        // 接続に成功した(接続状態である)
                        Console.WriteLine("[OK] SSH Connection succeeded!!");
                    }
                    else
                    {
                        // 接続に失敗した(未接続状態である)
                        Console.WriteLine("[NG] SSH Connection failed!!");
                        return;
                    }
    
                    // 接続終了
                    ssh.Disconnect();
                }
                catch(Exception ex)
                {
                    // エラー発生時
                    Console.WriteLine(ex);
                    throw ex;
                }
                
            }
        }
    }
    

    パスワード認証でSSH接続をしてみた例になります。
    接続できない場合は、ファイアウォールやポートフォワーディング設定など、ネットワーク接続自体に問題がないかを確認してみてください。

  4. コマンドを送信してみる(リモートマシンの時間を取得)
    using System;
    using Renci.SshNet;
    
    namespace RemoteServerConnect
    {
        class Program
        {
            static void Main(string[] args)
            {
                try
                {
                    // 接続先のホスト名またはIPアドレス
                    var hostNameOrIpAddr = "192.168.xxx.xxx";
    
                    // 接続先のポート番号
                    var portNo = 22;
    
                    // ログインユーザー名
                    var userName = "taki";
    
                    // ログインパスワード
                    var passWord = "xxxxxxxxxxxxxx";
    
                    // コネクション情報
                    ConnectionInfo info = new ConnectionInfo(hostNameOrIpAddr, portNo, userName,
                        new AuthenticationMethod[] {
                            new PasswordAuthenticationMethod(userName, passWord)
                            /* PrivateKeyAuthenticationMethod("キーの場所")を指定することでssh-key認証にも対応しています */
                        }
                    );
    
                    // クライアント作成
                    SshClient ssh = new SshClient(info);
    
                    // 接続開始
                    ssh.Connect();
    
                    if(ssh.IsConnected)
                    {
                        // 接続に成功した(接続状態である)
                        Console.WriteLine("[OK] SSH Connection succeeded!!");
                    }
                    else
                    {
                        // 接続に失敗した(未接続状態である)
                        Console.WriteLine("[NG] SSH Connection failed!!");
                        return;
                    }
    
                    // 送信したいコマンドを変数に入れる
                    var commandString = "date";
    
                    // コマンドを作成する
                    SshCommand cmd = ssh.CreateCommand(commandString);
    
                    // コマンドを実行する
                    Console.WriteLine("[CMD] {0}", commandString);
                    cmd.Execute();
    
                    // 結果を変数に入れる
                    var stdOut = cmd.Result;
                    var stdErr = cmd.Error;
    
                    // 終了コードを表示する
                    Console.WriteLine("終了コード:{0}", cmd.ExitStatus);
    
                    // 標準出力を表示する
                    if (stdOut != string.Empty)
                    {
                        Console.WriteLine("標準出力:");
                        Console.WriteLine(stdOut);
                        Console.WriteLine("---------");
                    }
    
                    // エラー出力を表示する
                    if (cmd.ExitStatus != 0 && stdErr != string.Empty)
                    {
                        Console.WriteLine("標準エラー出力:");
                        Console.WriteLine(stdErr);
                        Console.WriteLine("----------------");
                    }
    
                    // 接続終了
                    ssh.Disconnect();
                }
                catch(Exception ex)
                {
                    // エラー発生時
                    Console.WriteLine(ex);
                    throw ex;
                }           
            }
        }
    }
    

    今回はサンプルとして、リモート側の時間を取得してコンソールに表示するサンプルを作ってみました。

  5. こんなことに使えます
    SSHをターミナル以外のWindowsクライアントから送信するという事自体が滅多にないことだとは思いますが
    Webクライアントが使えない環境や、サーバーへのSSHアクセスはさせたいがコマンドをテンプレート化して使用させるなどの制限をしたい場合などに使えるかもしれません。
    もちろん、このライブラリを使ってターミナルクライアントを作ることも可能だと思います。

MySQL接続のやりかた

  • 必要なもの
    • MySQLサーバーが起動していて接続が可能なリモート環境(物理マシンでもVMでもOK)
      • 記事内で使用している環境はLinux CentOS 6.9 / MySQL5.5.50(VirtualBox使用)
    • VisualStudio(どのエディションでも可)
      • 記事内で使用している開発環境はVisualStudio2017
  1. 新しいプロジェクトを作成または既存のプロジェクトを読み込む
    このサンプルではConnectMysqlというプロジェクトをコンソールプロジェクトで作成しました。
    既存のプロジェクトでテストしていただいても問題ありませんが、できれば新規作成をおすすめします。

  2. NuGetからMysql.Dataの追加
    図のように、ソリューションエクスプローラー内で1で作成したプロジェクト名の項目を右クリックし、NuGetパッケージの管理をクリックします。参照タブをクリックし、検索フォームに「Mysql」と入力し検索します。
    Oracle製のMysql.Dataをプロジェクトにインストールします。

  3. とりあえず接続してみる
    以下にサンプルコードを記載します。コードの説明はコメントに書いております。

    using System;
    using MySql.Data.MySqlClient;
    
    namespace ConnectMysql
    {
        class Program
        {
            static void Main(string[] args)
            {
                // 接続に必要なパラメータ文字列を生成する
                var connectionParams = string.Format(
                    "host={0}; userid={1}; password={2}; database={3}; charset={4}",
                    "192.168.xxx.xxx",
                    "taki",
                    "xxxxxxxxxxxx",
                    "sample_db",
                    "utf8"
                );
    
                // コネクターを作成
                var mysql = new MySqlConnection(connectionParams);
    
                try
                {
                    // 接続開始
                    mysql.Open();
                }
                catch (MySqlException ex)
                {
                    // 何らかの理由で接続に失敗した
                    Console.WriteLine(ex);
                    throw ex;
                }
            }
        }
    }
    

    リモートにインストールされたMySQLサーバーにアクセスする例になります。
    接続できない場合は、MySQLユーザーのアクセス権、ファイアウォールやポートフォワーディング設定など、設定に問題がないかを確認してみてください。

  4. insertとselectをしてみる
    using System;
    using MySql.Data.MySqlClient;
    
    namespace ConnectMysql
    {
        class Program
        {
            static void Main(string[] args)
            {
                // 接続に必要なパラメータ文字列を生成する
                var connectionParams = string.Format(
                    "host={0}; userid={1}; password={2}; database={3}; charset={4}",
                    "192.168.xxx.xxx",
                    "taki",
                    "xxxxxxxxxxx",
                    "sample_db",
                    "utf8"
                );
    
                // コネクターを作成
                var mysql = new MySqlConnection(connectionParams);
    
                try
                {
                    // 接続開始
                    mysql.Open();
                }
                catch (MySqlException ex)
                {
                    // 何らかの理由で接続に失敗した
                    Console.WriteLine(ex);
                    throw ex;
                }
    
                // サンプルではこういうテーブルにアクセスしている
                // CREATE TABLE shoplineup(ID INT AUTO_INCREMENT NOT NULL PRIMARY KEY, Name VARCHAR(16) NOT NULL, Price INT NOT NULL);
    
                // コマンド文字列用変数
                string cmd = "";
    
                // insertしてみる
                cmd = @"INSERT INTO shoplineup SET Name='Sword', Price=100;";
    
                // コマンドコンポーネント作成
                var insertCmd = new MySqlCommand(cmd, mysql);
    
                // 実行
                MySqlDataReader insertResult = insertCmd.ExecuteReader();
    
                // 変更を与えた件数を表示してみる
                Console.WriteLine("{0}row affected", insertResult.RecordsAffected);
    
                // 結果を閉じる
                insertResult.Close();
    
                // selectしてみる
                cmd = @"SELECT * FROM shoplineup;";
    
                
    
                // コマンドコンポーネント作成
                var selectCmd = new MySqlCommand(cmd, mysql);
    
                // 実行
                MySqlDataReader selectResult = selectCmd.ExecuteReader();
    
                // MySqlDataReaderというクラスに結果が入っている
                // Read()を呼ぶことで次の行にアクセスする
    
                while(selectResult.Read())
                {
                    var id = selectResult.GetInt32("ID");  // フィールド名でのアクセス
                    var name = selectResult.GetString(1);  // カラムインデックスでのアクセス
                    var price = selectResult.GetUInt32(2); // 同上
    
                    Console.WriteLine("ID:{0} Name:{1} Price:{2}", id, name, price);
                }
    
                // 結果を閉じる
                selectResult.Close();
    
                // Mysql接続終了
                mysql.Close();
            }
        }
    }
    

    今回はサンプルとして、sample_dbというデータベースにあるshoplineupテーブルに対してinsertおよびselectをする例を作ってみました。

  5. DataTableで欲しい場合

    var datatable = new DataTable();
    var adapter = new MySqlDataAdapter(query, mysql);
    adapter.Fill(datatable);
    return datatable;

    このようにして、クエリを実行する部分にMySqlDataAdapterを使用することで結果をDataTableで得ることも出来ます。

  6. こんなことに使えます
    MySQLターミナルクライアントを作ることも出来ますし、機能をテンプレート化してデータの集計用クライアントとしても使うことが出来ます。

まとめ

SSH接続及びMySQL接続をC#でやってみるというチャレンジでしたが、いかがでしたでしょうか?
パッケージのおかげで誰でも簡単に実装出来るので、アイディア次第ではとても便利なアプリを作ることが出来ると思います。
C#でSSH接続やMySQL接続をやってみたいという方のお役に立てれば幸いです。


平井 佑樹

ログレスマップエディタのパフォーマンスを改善したお話


はじめまして、17年度新卒エンジニアの平井です。

今回、新卒エンジニアの平井と西田の二人がパフォーマンス向上のため新たにUnityでマップエディタを開発しました。
その際に改善した点や苦労した点を紹介しようと思います。

マップエディタとは

弊社の「剣と魔法のログレス いにしえの女神」及び「ブラウザ版 剣と魔法のログレス」では、自社GUIツールを使用してマップデータを制作しています。地面タイルの配置や噴水などのオブジェクトを配置するだけでなく、キャラクターが移動できない位置の指定や影の設定など、マップ処理に関する様々な設定も可能です。

~新マップエディタ画面~

ログレスの旧マップエディタは、Adobe AIRで作られています。これは、先に開発されたブラウザ版ログレスがFlashで作られているため、マップ上に配置されたオブジェクトを表示したりアニメーションをしたりするためのコンポーネント群を、ゲームクライアントとマップエディターとで共有ができ、開発効率が高いためです。

旧マップエディタの問題点

旧マップエディタは以下の問題がありました。

  • ファイル読み込みに時間がかかる
  • 描画速度が遅い
  • 弊社でAdobe AIRを使えるエンジニアが少なくメンテナンス性が低い

新マップエディタでは、これらの問題を改善することが目標となります。

Unityで開発した理由

  • 弊社には Unity を使えるエンジニアが多くメンテナンス性が向上できる
  • 弊社が Unity を使った開発を活発に行なっており、情報収集が容易かつノウハウを蓄積できる
  • 新卒エンジニアとして Unity を覚えて基本的な開発方法を知っておきたい、使えるようになっておきたい

今回、Unityのエディタ拡張は行わずツールアプリとして運用します。
Unityのエディタ拡張を使わない理由は、マップデザイナーが新マップエディタを使う際に
旧マップエディタと同じ操作感に近づけるためです。

目標

  • 旧マップエディタの機能を再現
  • パフォーマンス改善

これらの目標を達成するために苦労した部分や工夫した部分を解説していきたいと思います。

旧マップエディタの機能を再現

基本的な配置機能は問題なく実現できました。
しかし、旧マップエディタはFlash固有の機能を使って表現している部分があり、Unityのスプライト標準機能では表現できませんでした。

これを解決するためにUnityのシェーダー機能を使って表現しました。

~マスクブレンド~

~カラーブレンド~

ここまでは順調に開発できましたが、Unityで開発していくにあたってパフォーマンス周りの問題が発生しました。

パフォーマンス改善

  • マップファイルの読み込みに時間がかかる

ログレスの標準のマップは平均で10万個のチップやオブジェクトが配置されています。新マップエディタではUnityのGameObjectを10万個作成すると処理が重くなるため、カメラに映し出される箇所のGameObjectのみ生成し、それ以外はデータだけ保持するようにしました。それにより従来6分かかったマップを1分足らずで展開することが出来ました。

  • 描画速度が遅い

マップを読み込み時にチップやオブジェクト(GameObject)をインスタンス化した際にマテリアルも複製されていたためドローコールが増えていました。そのため、インスタンス化する際、1つのマテリアルでチップやオブジェクトを生成して、そのマテリアルのテクスチャを差し替えることで800ぐらいあったドローコールを20~30にまで削減しました。
さらに、カメラに映し出される部分のみチップを生成し、画面スクロールした際、そのチップのテクスチャのみ変更して最小限のGameObjectで描画できるようにしました。その結果、マップ読み込み時のチップ生成速度や描画速度が向上し、スムーズな視点移動が出来るようになりました。

結果

旧マップエディタでも上記の最適化は行われていましたが、平均で20~30FPSしか出ませんでした。しかし、新マップエディタでは常時60FPSを維持することが出来、描画速度の改善を達成できました。

~カメラに映る画面~

~カメラの範囲外のオブジェクト~

まとめ

旧マップエディタと比べてパフォーマンスが向上し、より快適にマップ制作が出来るようになったと思います。Unityで制作したため今後のメンテナンス性はAdobe AIRと比べてかなり上がり、今後の要望にも迅速に対応できるようになりました。

振り返り

Unityを使ったことによりプロトタイプを早く作成することが出来ました。
処理の最適化をする際にとても苦労しましたが、今思えばどうすれば処理を軽くできるかを試行錯誤して、とても良い経験になったと思います。
マップエディタを作るにあたり、どうすれば使いやすいのかを、もう一人の新卒メンバーの西田さんや先輩と話し合いながら取り組みました。
新マップエディタの制作で得た経験を、今後の業務に活かしていきたいと思っています。

ありがとうございました。


syoshino

AssetBundleGraphTool を触ってみました


エンジニアの吉野です。

先日アップデートされたばかりの AssetBundleGraphTool v1.3 をこれまた先日リリースされた Unity 2017.1 に入れてみました。

AssetBundleGraphTool の bitbucket ページ
日本語マニュアル

今回はゲームで使用する Asset がどの AssetBundle に入っているかのテーブルデータをカスタムノードで作成したのでかいつまんで紹介しようと思います。

概要

ゲームで Asset を読むときに、開発中はローカルから、実機では AssetBundle を使いたいということが良くあります。
(Unite 2017 で紹介されていた Addressable Assets が入ればいらなくなるはずなので、それまでのつなぎで…)
これを満たすための仕様としては、おおむね以下の図のようになると思います。

asset load flow

この図の AssetBundleTable で使う AssetPath → AssetName 変換用のデータをカスタムノードで作ります。

完成したGraph図はこのようになります。

graph sample

カスタムノードの作り方は日本語のマニュアルの「カスタムノードを追加する」を見てください。
以下、作成したノードの要点を解説していこうと思います。

入力/出力するノードのタイプを指定する

今回は、「AssetBundle 名と AssetPath が設定されたノード」(Grouping)と 「AssetBundle をビルドするノード」(BundleBuilder)の間に入れたいので、以下のようにします。


    public override Model.NodeOutputSemantics NodeInputType
    {
        get
        {
            return Model.NodeOutputSemantics.AssetBundleConfigurations;
        }
    }

    public override Model.NodeOutputSemantics NodeOutputType
    {
        get
        {
            return Model.NodeOutputSemantics.AssetBundleConfigurations;
        }
    }

インスペクター上から AssetBundleTable を指定できるようにする

Inputされた情報を書き込む AssetBundleTable を渡せるようにします。
指定はエディタ拡張でおなじみの OnInspectorGUI(…) 部分に追加します。


public override void OnInspectorGUI(
    NodeGUI node,
    AssetReferenceStreamManager streamManager,
    NodeGUIEditor editor,
    Action onValueChanged)
{
...
    tableAsset = EditorGUILayout.ObjectField("AssetBundleTable Object", tableAsset, typeof(AssetBundleTable), false) as AssetBundleTable;
...
}

ビルド時に AssetBundleTable を更新する

Graph をビルドすると、カスタムノードの Build(…) が呼ばれます。この時に受け取った情報をTableに設定します。


public override void Build(
    BuildTarget target,
    Model.NodeData node,
    IEnumerable<PerformGraph.AssetGroups> incoming,
    IEnumerable<Model.ConnectionData> connectionsToOutput,
    PerformGraph.Output Output,
    Action<Model.NodeData, string, float> progressFunc)
{
...
    tableAsset.Clear();
    foreach (var ag in incoming)
     {
         foreach (var assetGroup in ag.assetGroups)
         {
             foreach (var inputAsset in assetGroup.Value)
             {
                 tableAsset.Add(new AssetBundleLoadPath(assetGroup.Key, inputAsset));
             }
         }
     }
     EditorUtility.SetDirty(tableAsset);
     AssetDatabase.SaveAssets();
...
}

AssetBundleTable 自身も AssetBundle にする

実機で使うので、AssetBundleTable も忘れずに AssetBundle にします。
幸いこの後に AssetBundle をビルドするノードがあるので、Output に追加して流します。


public override void Build(
    BuildTarget target,
    Model.NodeData node,
    IEnumerable<PerformGraph.AssetGroups> incoming,
    IEnumerable<Model.ConnectionData> connectionsToOutput,
    PerformGraph.Output Output,
    Action<Model.NodeData, string, float> progressFunc)
{
...
    EditorUtility.SetDirty(tableAsset);
    AssetDatabase.SaveAssets();

    if (Output != null)
    {
        var dst = (connectionsToOutput == null || !connectionsToOutput.Any()) ?
            null : connectionsToOutput.First();

        // 上流のノードから流れてきたものをそのまま渡します。
        foreach (var ag in incoming)
        {
            Output(dst, ag.assetGroups);
        }

        // AssetBundleTableを追加して渡します。
        var additionalOutput = new Dictionary<string, List<AssetReference>>();
        additionalOutput.Add(settings.tableAssetBundleName,
            new List<AssetReference> { 
                AssetReference.CreateReference(AssetDatabase.GetAssetPath(tableAsset), typeof(AssetBundleTableListData))
            }
        );
        Output(dst, additionalOutput);
    }
}

これで、作成した Graph がビルドされるたびに AssetBundleTable が更新されて、AssetBundle にまでなってくれます。
日本語のわかりやすいマニュアルがあるおかげで、すんなりと実装できてしまいました。

使ってみた感想

ノードの拡張機能があるおかげで Asset を読んで加工してなにかする、という機能をグラフィカルかつ低コストで作成できる素晴らしいツールだと思います。
カスタムノード以外にもいろいろな機能があるのでぜひマニュアルを見てみてください。
いろいろ応用できそうなので今後も活用していこうと思います!


chiepomme

静的コード生成を用いたシリアライゼーション高速化


こんにちは、とある新規開発プロジェクトで主に C# でクライアントとリアルタイムサーバーを書いている 水上智絵(@chiepomme)です。今回は私のプロジェクトで行った、C#におけるシリアライゼーションの高速化についてお話ししたいと思います。

はじめに

いま私が関わっているプロジェクトで以前、通信が多くなると動作がカクカクしてしまう問題が出ていました。Unity のプロファイラで調査したところ、ほとんどがシリアライズ・デシリアライズのコストだったことから、シリアライズの処理を高速化する必要が生まれました。今回はその方法についてご紹介します。

そもそもシリアライズってなんだ!

シリアライズというのは、プログラムの中のオブジェクトを、ファイルに書き込んだり通信に乗せたり出来るようなバイト列(文字列)に変換することです。逆にバイト列からプログラム中のオブジェクトに変換することをデシリアライズといいます(由来は知らないのですが、シリーズとかシリアルと同じ言葉なので、一続きのもの、つまりバイト列一本にするからシリアライズというイメージで私は考えてます)。

たとえば、以下のようなクラスがあったとします。

class SerializableObject
{
    public int A = 10;
    public int B = 20;
}

これをファイルに保存したい、通信に乗せたいとなった場合には、「A:10, B:20」のようなテキストでの表現や、値である「10 20」をそれぞれバイト列に変換してつなげるというのが素直な方法だと思います。こういった処理をシリアライゼーション、動詞だとシリアライズする、と言います。

リフレクションを用いた汎用的なシリアライザーとパフォーマンス

さて、こうしたシリアライゼーションは小さな物であれば個別のクラス毎に定義することが可能ですが、規模が大きくなってくるとそうするのは現実的では無くなってきてしまいます。そこで色んなクラスに汎用的に使えるシリアライザーが必要となります。

汎用的なシリアライザーとしては代表的なのは C# を使い始めた頃きっとだれでもお世話になるであろう XmlSerializer です。コードは GitHub で見られます。
https://github.com/dotnet/corefx/tree/master/src/System.Xml.XmlSerializer/src/System/Xml

このコードを見てもらうと分かるのですが、通常こういったシリアライザーを作る場合にはリフレクションが必要となります。しかしリフレクションは動作が遅く、保存データの読み書きであれば問題になることは少ないと思いますが、リアルタイム通信に用いるような箇所に使用すると速度的にボトルネックになりやすいという問題点があります。

最新の C# を使えてかつ動的コード生成に対応している環境であれば、様々な方法でリフレクションを用いずにも、動的にオブジェクトにアクセスすることが可能です。具体的な方法は以下の岩永さんのブログをご覧ください。

[雑記] 動的コード生成のパフォーマンス – C# によるプログラミング入門 | ++C++; // 未確認飛行 C

しかし、Unity の C# は若干古く、また iOS では動的コード生成ができないという制約があります。よって、私たちのプロジェクトでもキャッシュを用いて改善を試みましたが、それだけでは満足のいく改善が見られませんでした。その後も細かなチューニングをしてみたもののそもそものリフレクションのパフォーマンスの問題があるため、天井が見えてしまっていました。

リフレクションから静的コード生成へ

大きな改善のためには根本的な発想の転換が必要だと思い、動的な仕組みでは無くシリアライズの方法自体を静的に生成してしまうという方法を考えました。ただ C# には静的なコード生成をサポートするような仕組みはないので、本当にコードそのものを生成してあげる必要があります(幸い Aiming には JSON Schema を用いた Generator のような、通信定義 DSL を書くことで通信ファイルを生成する仕組みがあるため、それに載せて使用しています)。

では、実際に C# のコードで比較してみましょう。

シリアライズしたいクラス

話を単純にするために以下のような、入れ子を伴わず、フィールドの型も int もしくは string のみのクラスを対象とします。また、シリアライズ先のフォーマットはテキストで「フィールド名<タブ>値<改行>…」とします。

public class SimpleSerializableObject
{
    public int Int1;
    public int Int2;
    public int Int3;

    public string String1;
    public string String2;
    public string String3;
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/Serialization/SimpleSerializableObject.cs)

リフレクションを使用したコード

using System;
using System.Reflection;
using System.Text;

namespace Serialization
{
    public class StringSerializerWithReflection
    {
        public string Serialize<T>(T serializableObject) where T : new()
        {
            var stringBuilder = new StringBuilder();

            // シリアライズ対象クラスの全てのフィールドを取得
            var fields = typeof(T).GetFields(BindingFlags.GetField | BindingFlags.Public | BindingFlags.Instance);
            foreach (var field in fields)
            {
                // キー + タブ + 値 の形で文字列にする
                stringBuilder.AppendLine(field.Name + "\t" + field.GetValue(serializableObject));
            }
            return stringBuilder.ToString().Replace("\r\n", "\n");
        }

        public T Deserialize<T>(string serializedString) where T : new()
        {
            var resultObj = new T();
            // デシリアライズ対象クラスの全てのフィールドを取得
            var fields = typeof(T).GetFields(BindingFlags.SetField | BindingFlags.Public | BindingFlags.Instance);

            foreach (var line in serializedString.Split('\n'))
            {
                // キー + タブ + 値 の形で格納されているのを取り出す
                if (string.IsNullOrEmpty(line)) continue;
                var nameAndValue = line.Split('\t');
                var name = nameAndValue[0];
                var rawValue = nameAndValue[1];
                var field = Array.Find(fields, (e) => e.Name == name);

                SetValueToField(resultObj, field, rawValue);
            }

            return resultObj;
        }

        void SetValueToField<T>(T resultObj, FieldInfo field, string rawValue)
        {
            // テキストをフィールドの型でパースして実際の値にする
            if (field.FieldType == typeof(int))
            {
                field.SetValue(resultObj, int.Parse(rawValue));
            }
            else if (field.FieldType == typeof(string))
            {
                field.SetValue(resultObj, rawValue);
            }
        }
    }
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/Serialization/StringSerializerWithReflection.cs)

シリアライズ時には、リフレクションで全てのフィールドを走査して、それぞれの名前をキーとして StringBuilder に追加していき、最終的な文字列を構築します。デシリアライズ時にはその逆のことを行うだけの単純な物です。

静的コード生成による方法

リフレクションを使わず静的コード生成を用いる時には、ビルドより前にシリアライズ対象のクラスに対応したシリアライズ・デシリアライズメソッドを生成する必要があります。今回の場合にリフレクションを使わずリフレクションと同じ事をするためのコードは以下のようになるでしょう。

// このファイルは StaticStringSerializerGenerator によって生成されました
using System.Text;

namespace Serialization
{
    public partial class SimpleSerializableObject
    {
        public string Serialize()
        {
            // キー + タブ + 値 の形で文字列にする
            var sb = new StringBuilder();
            sb.AppendLine("Int1\t" + Int1);
            sb.AppendLine("Int2\t" + Int2);
            sb.AppendLine("Int3\t" + Int3);
            sb.AppendLine("String1\t" + String1);
            sb.AppendLine("String2\t" + String2);
            sb.AppendLine("String3\t" + String3);
            return sb.ToString().Replace("\r\n", "\n");
        }

        public SimpleSerializableObject(string serializedString)
        {
            foreach (var line in serializedString.Split('\n'))
            {
                // キー + タブ + 値 の形で格納されているのを取り出す
                if (string.IsNullOrEmpty(line)) continue;
                var nameAndValue = line.Split('\t');
                var name = nameAndValue[0];
                var rawValue = nameAndValue[1];

                switch (name)
                {
                    case "Int1": Int1 = int.Parse(rawValue); break;
                    case "Int2": Int2 = int.Parse(rawValue); break;
                    case "Int3": Int3 = int.Parse(rawValue); break;
                    case "String1": String1 = rawValue; break;
                    case "String2": String2 = rawValue; break;
                    case "String3": String3 = rawValue; break;
                }
            }
        }
    }
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/Serialization/SimpleSerializableObject.Serializer.cs)

これは、シリアライズ対象の SimpleSerializableObject を 以下のような Generator に通して自動生成したものになります。

using System;
using System.IO;
using System.Reflection;
using System.Text;

namespace SerializerGenerator
{
    class StaticStringSerializerGenerator
    {
        public void Generate(Type type)
        {
            File.WriteAllText(type.Name + ".Serializer.cs", Stringify(type));
        }

        public string Stringify(Type type)
        {
            var fields = type.GetFields(BindingFlags.GetField | BindingFlags.Public | BindingFlags.Instance);

            // 本当はハードコードじゃなくて何かしらのテンプレートを使ってね!
            var sb = new StringBuilder();
            sb.AppendLine(@"// このファイルは StaticStringSerializerGenerator によって生成されました");
            sb.AppendLine(@"using System.Text;");
            sb.AppendLine();
            sb.AppendLine(@"namespace " + type.Namespace);
            sb.AppendLine(@"{");
            sb.AppendLine(@"    public partial class " + type.Name);
            sb.AppendLine(@"    {");
            sb.AppendLine(@"        public string Serialize()");
            sb.AppendLine(@"        {");
            sb.AppendLine(@"            var sb = new StringBuilder();");
            // 全フィールドの値を出力する部分を生成
            foreach (var field in fields)
            {
                sb.AppendFormat(@"            sb.AppendLine(""{0}\t"" + {0});", field.Name);
                sb.AppendLine();
            }
            sb.AppendLine(@"            return sb.ToString().Replace(""\r\n"", ""\n"");");
            sb.AppendLine(@"        }");
            sb.AppendLine();
            sb.AppendLine(@"        public " + type.Name + "(string serializedString)");
            sb.AppendLine(@"        {");
            sb.AppendLine(@"            foreach (var line in serializedString.Split('\n'))");
            sb.AppendLine(@"            {");
            sb.AppendLine(@"                if (string.IsNullOrEmpty(line)) continue;");
            sb.AppendLine(@"                var nameAndValue = line.Split('\t');");
            sb.AppendLine(@"                var name = nameAndValue[0];");
            sb.AppendLine(@"                var rawValue = nameAndValue[1];");
            sb.AppendLine();
            sb.AppendLine(@"                switch (name)");
            sb.AppendLine(@"                {");

            // 全フィールドのパース部分を生成
            foreach (var field in fields)
            {
                if (field.FieldType == typeof(int))
                {
                    sb.AppendFormat(@"                    case ""{0}"": {0} = int.Parse(rawValue); break;", field.Name);
                }
                else if (field.FieldType == typeof(string))
                {
                    sb.AppendFormat(@"                    case ""{0}"": {0} = rawValue; break;", field.Name);
                }
                sb.AppendLine();
            }

            sb.AppendLine(@"                }");
            sb.AppendLine(@"            }");
            sb.AppendLine(@"        }");
            sb.AppendLine(@"    }");
            sb.AppendLine(@"}");

            return sb.ToString();
        }
    }
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/SerializerGenerator/StaticStringSerializerGenerator.cs)

コードはだいぶ読みづらくなってしまっていますが、先ほどのリフレクションを使わずにシリアライズするコードをテンプレートとして、個別のフィールドの部分を動的に生成するようにしたコードです。

このようにクラス毎のシリアライズのコードをファイルとしてそのまま生成してしまうというのが、静的コード生成の基本的な戦略になります。

リフレクションによるものと静的コード生成のパフォーマンスの比較

実際に上記の2つの方法+キャッシュ有りを比較したのが以下のグラフになります。

シリアライズ(1000回)

シリアライズ(100000回)

試行回数を問わず、静的コード生成バージョンはリフレクションを用いる物に比べて、最低でも2倍以上の速度が出るようになりました。

なお、シリアライザーのコードも計測コードもまとめて chiepomme/SampleSerializer にアップしてありますので、興味があれば是非ご覧ください。

おわりに

動的なものに比べるとこういった静的なコード生成は泥臭い印象がありますが、速度面で有利なことが多いです。その一方でコードのサイズは膨れてしまうので、場合によっては使用するべきではないシチュエーションもあると思います。このあたりはプロジェクトによるところなので、より理想に近い物を選んでいきたいですね。

ちなみに実際のプロダクトでは、シリアライズのフォーマットは MessagePack を用い、キーとしてフィールドの名前を使わず数字を使うなどの方法でさらに最適化していますが、基本的な考え方はこの記事の内容と同じです。シリアライズ周りのパフォーマンスで困っている方がいらっしゃったら何かの参考になればうれしいです。


hirooka

C# + MySQL + Dapper で軽量 O/R Mapper


エンジニアの廣岡です。

最近は仕事で C# を用いたサーバを書いていますが、C# で O/R Mapper (ORM) を使ったことがなくて「とりあえず動く軽量なものが欲しい」と思って見つけ出した Dapper という Micro-ORM を紹介していきます。

Dapper: https://github.com/StackExchange/dapper-dot-net

ところで Micro-ORM とは、ORM の機能のうちいくつかの機能が無いものを指すようですが、「SQL 文を作るクエリビルダの機能」がないものを指すことが多いようです。

C# + MySQL + Dapper

C# から MySQL に接続するときは、ADO.NET インターフェイスを持った MySQL 公式の Connector/Net を使うのが無難でしょう。
http://dev.mysql.com/downloads/connector/net/
これを使えば基本的な MySQL への接続・SQL 実行などはできるようになるのですが、IDataReader や DataSet といったややローレベルなインターフェイスなのでちょっと使いにくいです。
そこで ORM が登場するわけですが、Entity Framework のようなリッチな ORM の導入がちょっとめんどくさかった(というか勉強不足だった)ので、超軽量な ORM は無いのかな〜と思って探していたら Dapper という Micro-ORM を見つけました。
これがどう軽量なのかというと

  • ライブラリは SqlMapper.cs ファイルだけ(数千行ありますが)
  • 基本的には ADO.NET の IDBConnection に拡張メソッドを追加しているだけ

というもので、基本は ADO.NET として使えばよくて、SQL クエリの結果をクラスや構造体にマッピングしたい時に Dapper による拡張メソッドを呼び出すというものです。
クエリビルダは無くデータマッピングしかしないのですが、今すぐ欲しい物としては十分な機能だったので採用しました。

サンプルコード

Dapper の IDbConnection.Query を使って DB 上の Users テーブルから User クラスの一覧を取得するコード

class User
{
    public int Age { get; set; }
    public string Name { get; set; }
}

using (var connection = new MySQLConnection("設定"))
{
    connection.Open();
    var users = connection.Query<User>("SELECT * FROM Users"); // この行だけ Dapper
    foreach (var user in users)
    {
        Console.WriteLine(user.Name);
    }
}

Dapper の IDbConnection.Execute を使って DB 上の Users テーブルにレコードを挿入するコード
(あまり ORM の恩恵がないケース)

using (var connection = new MySQLConnection("設定"))
{
    connection.Open();
    var numOfAffected = connection.Execute("INSERT INTO Users (Age, Name) VALUES (23, Alice)");
}

Dapper で満足?

Dapper は急ぎで欲しかった機能を提供してくれました。ただその後、クエリビルダがないことによって恥ずかしながらテーブル名をタイポしてしまい、気づきにくいバグを作ってしまいました。
この種のバグを二度と作らないためにも Dapper 用にクエリビルダを導入するか、Linq to Entities のようなフル ORM に乗り換えるか、検討をはじめています。

Dapper は ADO.NET をもう少し便利に使いたいなーというシーンには使いどころもありますが、ゲームのサーバ側の MySQL 用に使うには機能不足感があると思います。

Unity 上で SQLite を扱うときなんかにはこの軽量さが発揮されそうですね。


sindharta

幻塔戦記グリフォンの AI で使っている Behaviour Tree


こんにちは、クライアントエンジニアの Sindharta Tanuwijaya(シンダルタ タヌイジャヤ)です。

今更ですが、1月の社内の勉強会で、 Behaviour Tree という AI の手法を発表させて頂きました。当時は幻塔戦記グリフォンを開発するのにあたって、1つのフィーチャーを完成させるためにこの機能を作っていましたが、今はいろいろなフィーチャーで使われています。

Behaviour Tree とは思考 AI のアルゴリズムの1つで、比較的に良く知られているステートマシンと目的が似ています。それはゲーム内のオブジェクトをどう考えさせて、行動させることです。ステートマシンも良い手法ですが、 AI が複雑になってくるのにつれて、管理の難しさが倍に増えるデメリットがあります。そこで、 Behaviour Tree を導入してみたわけです。

当日発表したスライドは以下です。

また、自分の発表をさらに分かりやすくするために、動画も作りました。

折角ですので、私が参考にしたリソースも伝えたいと思います。すべて英語ですが。

  1. Introduction to Behavior Trees
  2. Unity 4.x Game AI Programming

ちなみに、 Behave という Behaviour Tree をデザインするための Unity のプラグインはあります。とても良いプラグインですが、様々な面を検討した結果、今回私たちは独自のものを作ることになりました。Behave が持っているエディター機能をはやく見習わないといけないですね。

Behaviour Tree という手法はコンシューマーゲームでは約10年前に導入されていたと思いますが、いよいよ今になってスマートデバイスのゲームに導入してもおかしくはない時代になってきています。スマートデバイスがリッチになってきた証拠です。Aiming でこれからもどんどんリッチなゲームを開発していきたいと思います!

最後にちょっとしたネタがあります。Behaviour も、u なしの Behavior も両方見かけたことがありませんか?実はu なしの Behavior はよく米国で使われているらしく、イギリスなどといった他のところでは Behaviour は正しいそうです。個人的にはu ありの Behaviour が格好よく見えるので、それを選ばせていただきました。


ppc(細田幸治)

Unityで使える非同期処理のクラスライブラリ(iterator-tasks)を公開しました


こんにちは。Aiming 東京開発グループの細田です。
私たちのチームで開発したロードオブナイツで使っている非同期処理ライブラリを公開しました!!

github のアドレスはこちら
https://github.com/aiming/iterator-tasks

サンプルとテストコード付いてます。

設計者は ++C++; の中の人こと岩永信之さんです。ネットでC#について検索したことがある方なら一度はサイトを見たことあると思います。

公開したクラスをどんな用途で使っているか、どんな設計で出来ているかについては以下のスライドをご参照ください。

.NET 4.0 の Task のような機能を Unity でも使えるようになります。
一般的なゲームで実装されるタスクとはちょっと意味合いが異なり、非同期で動き、必ず終了する処理について使いやすくするためのライブラリです。
WWW での異常系実装やシーケンシャルな非同期処理の実装が簡単に出来るので是非使ってみてください。

 


ppc(細田幸治)

Lord of Knightsの開発裏側みせます。のスライド公開


こんにちは細田です。

4/10に行われた「パソナテック エンジニアカフェ×Aiming Lord of Knights の裏側見せます!
で弊社で開発運営している Lord of Knights のクライアントとサーバー開発について発表を行いました。

当日は120名以上の方にご参加いただき大盛況でした。皆さまありがとうございます!

1つのオンラインゲームについてクライアント側とサーバー側との両方の話ができた珍しい勉強会だったと思います。

続きを読む