20200206のC#に関する記事は3件です。

実装方針に関する最近の考えのメモ+α

実装方針に関する最近の考えのメモです。もしかしたら来年あたりには全く変わってしまっているかもしれないので、いまのうちに書き残しておきます。

本稿が他の人の参考になるかどうかは知りません。もし他の人に1つだけ推すとしたら、それは「クエリごとに .sql ファイルとクラスを作る」の部分です。

文脈

主に業務系アプリを対象としています。

一部の用語を DDD やクリーンアーキテクチャ (CA) から借りている部分がありますが、アーキテクチャだけが DDD ではないし、CA としては不完全だと思うので、そういうのは名乗らないことにします。

ソースコードの配置

何をどう書くか考える前に、まず どこに 書くかを考えます。だいたいこういう構成になるというものが自分の中で固まってきたので、ここに書きます。

なお、大文字・小文字や用語などは言語・フレームワークによって異なるので、雰囲気だけ書きます。例えば「ディレクトリを分ける」と書いている部分は、言語によってはモジュールやパッケージかもしれません。

さて、たいてい src 直下がトップレベルです。トップレベルには「外部システムとの連携」のためのディレクトリをシステムの種類ごとに置きます。ここでいう外部システムとは、データベース、メールサーバ、Web などです。アプリの特定の機能とは関連しない、例えば「データベースと接続する」「ウェブサーバーを起動する」といったものをおいておきます。特定の機能と密接に関連のあるものは、後述のドメインに置きます。

  • data
    • データベース関連の具体的なコード
  • web
    • Web 関連の具体的なコード
  • etc.

これらと同じ階層に、作ろうとしているアプリのためのディレクトリ (名前は app やアプリの名前) を置き、内側はドメインごとにディレクトリを分けます。ドメインはざっくり言って機能のグループのようなもので、例えば「ユーザ(認証)」とか「注文」とかです。(いわゆる横割りというやつ。なぜ横なのかは知りません。)

  • app
    • ドメイン1
    • ドメイン2, ...
    • ドメインN

ドメインの中は逆にいわゆる縦割りで、書くものの意味ではなく形式で分けます。

  • (ドメイン i)
    • actions/
      • アクションごとのクラス
    • data/
      • クエリやコマンドごとのクラスと .sql ファイル
    • entities/
      • エンティティごとのクラス
    • views/
      • UI の実装
    • controller ファイル

それぞれ詳細に見ていきます。

app/xxx_domain/controller

コントローラーは1個のクラスであり、このドメインの外側に公開される唯一のものです。

ドメイン内の依存関係の最上位に位置し、内部にあるすべてのものを触ることができます。MVC の C とだいたい同じですね。ファットコントローラにならないように注意して書きます。

ディレクトリ内の依存関係は controller → actions → data/views → entities という一方向になります。data/views が互いを参照できないあたりは presentation domain separation (PDS) です。

app/xxx_domain/entities

entities は機能固有の実装を書く場所です。外部のサービスやフレームワークに依存しないクラスや関数からなります。データの運搬用の型とか、データ検証の判定関数、数値計算、データの形状の変形などが代表例です。

app/xxx_domain/views

UI の実装です。UI の実装に使うライブラリやフレームワークに依存します。

  • React なら renderXxx (関数コンポーネント) の詰め合わせになるでしょう。
  • WinForms なら Form などが入ります。
  • WPF なら Xaml と ViewModel クラスが入ります。

app/xxx_domain/data

data には、このドメインで使用する SQL のクエリやコマンドをまとめて配置します。冒頭に書いた通り、ここが一番書きたかったことで、ポイントは以下の2つです。

  • SQL は .sql ファイルに書く
  • SQL 文ごとにクラスを作る

ご存じの通り SQL を文字列連結で作るのはよくないです。動的な値はプリペアドステートメントの変数 (@name とか :name みたいなやつ) を使って埋め込みます。

動的な条件はなるべく SQL 側で解決します。例えば名前が入力されたら完全一致で絞り込み、入力されていなければ全件検索、という条件は以下のように書けます。

select users.user_id
from users
where @name is null or users.name = @name

ただ、どうしても動的 SQL に頼る場面があります。もっともよくあるのは IN 句に動的なリストを列挙するケースです。以下のように件数が固定の SQL になるケースは少ないでしょう。

where user_id in (@id1, @id2, @id3)

これは SQL の動的な書き換えによって対処します。具体的にどうやるかは場合によります。例えば C# なら StackOverflow/Dapper がこの機能を持つのでおすすめです。もし良いライブラリがなければ正規表現による置換 (IN \(@[a-z_]+\)IN (?, ?, ...)) などで頑張ることになるかもしれませんね……

そして、そういうダーティーな部分を隠すために SQL 文の利用を1個のクラスで覆っておきます。C# + Dapper ならこんな感じ。

using Dapper;

public sealed class FindUserQuery
{
    // SQL ファイルをリソースファイルに登録しておく。
    public string CommandText => Resources.FindUserQuery;

    public sealed class Param
    {
        public string name { get; set; }
    }

    public sealed class Result
    {
        public long user_id { get; set; }
    }

    public IEnumerable<Result> Find(Param param, IDbConnection connection, IDbTransaction transaction)
    {
        return connection.Query<Result>(CommandText, param, transaction);
    }
}

使う側は new FindUserQuery().Find(...)とやるだけです。Dapper を使っていることや、内部で SQL の動的な書き換えが行われていることなどは、クエリの実装詳細として隠蔽します。

app/xxx_domain/actions

アクションは先日「「アクション」の概念とエラー処理や通知の場所」に書いた通り、短時間で起こる一連の処理の最上位 です。それをクラスや関数として定義したものを1つ1つファイルとして配置します。

例えばボタンを押したときにデータを更新する機能があるなら、きっと Save アクションがここに配置されるでしょう。イメージ:

internal sealed class SaveAction
{
    // 状態へのアクセス
    private MyState State { get; }

    // UI へのアクセス
    private MyView View { get; }

    // DI されるサービス
    private IDatabase Database { get; }
    private ILogger Logger { get; }

    public SaveAction(MyState state, MyView view, IDatabase database, ILogger logger)
    {
        State = state;
        View = view;
        Database = database;
        Logger = logger;
    }

    public Execute()
    {
        try
        {
            Database.BeginTransaction((connection, transaction) =>
            {
                new Data.SaveCommand(State, connection, transaction).Execute();
            });

            View.NotifySuccess("保存しました。");
        }
        catch (Exception ex)
        {
            Logger.LogError(ex);
            View.NotifyError("保存に失敗しました。");
        }
    }
}

トランザクションを張ったり、例外をキャッチして異常系 (エラー処理) に遷移させるといった、入れ子になるべきでないものはだいたいここに置くことになります。

内容的には entities や controller に含めてもよさそうなものですが、わざわざ別のディレクトリに分けているのは 目立たせるため です。コードを読むときのとっかかり、と言い換えてもいいかもしれません。ソースコードを調査したり変更するために「どのコードを読めばいいか」を考えるとき、単独のファイルに SaveAction.cs というファイルがあれば、本稿のアーキテクチャを知らなくても「きっとここに保存処理が書かれてるに違いない」と分かるはずです。(たぶん)

ソースコードの性質

WHERE の次に WHAT を考えます。最終的なソースコードが満たすべき条件や性質の方向性のことです。

  • 動くこと

アプリがちゃんと動く、というのは当然ながら大前提です。課す条件がこれだけなら、特に設計は考えずに書きなぐればいいです。しかし実際には、以下のような性質もほしくなります。

  • 解読しやすさ

コードに関する調査を短時間で行える設計が好ましいです。ある機能に該当するコードがどのあたりにあるか、あるデータがどこから来てどこへ行くのか、といった調査をしやすい設計が好ましいです。

人によってはこれを「変更しやすさ」に含めるかもしれませんが、解読しやすさは「変更」には限らない場面で重要となります。例えば「この挙動はバグでは?」「この機能をこういう使い方できる?」みたいな質問を受けたり、別の箇所を実装・修正していて「既存のコードはどう作ってたっけ?」と疑問が生じたりしたときです。

言い換えると、リポジトリをデータベースに見立てたとき、CRUD のうち CUD (Create/Update/Delete) だけでなく R (Read) も重要です。もっとも、R はたいてい CUD に先立つものなので、含意されてると考えることもできますが。

  • 変更しやすさ

コードを変更するときにアプリが壊れにくい設計が好ましいです。

例えば本質的に同じコードが複数存在すると、その一部だけ変更してしまい、微妙なバージョン違いが発生するリスクがあります。再びデータベースに見立てると、これは正規化されていないデータベースへの更新異常のようなものです。

有効な対抗策は don't repeat yourself (DRY)、すなわち関数やクラスとして抽象化してそれを参照するコードで置き換えることですが、それだけではありません。

同じコードが複数存在するといっても、同じファイルの同じ場所に2つ連続で並んでいて、しかも「このコードは2つ連続で並んでいます」とコメントがついていたら、更新異常のリスクは現実的に十分少なくなります。一般化していうと、 同様のコードに印をつけておく 方法です。これは「DRY した方がいい気がするけど、本当に本質的に同じなのか、たまたま同じなだけなのか、まだ判断がつかない」とか「抽象化の方向性が思いつかない」といった躊躇の場面で有効な妥協案だと思います。

ビュー・ビューモデル実装についての細かいこと

views ディレクトリで少しだけ触れた WinForms/WPF に関しての考えも書いておきます。(React は何も考えず書けて最高!)

WinForms の MVP

以前に WinForms で MVVM をやろうと試みましたが、データバインディングの仕組みが微妙だったのでやめました。model-view-presenter (MVP) が無難な気がします。

WinForms で MVP をやるとき、おすすめなのは、Form に配置するコントロールのアクセス指定子 (modifier) を internal にしておいて、別のクラスに Form のインスタンスを持たせる構成です。言い換えると、Form の派生クラスの実装を別のクラスに書くということです。

Form には Windows を表示するという責務に専念してもらって、その上に配置しているテキストボックスとかの操作は取り除いた方が見通しが良い気がします。それに、Form はメンバが多いので、その中で作業すると入力補完がだるいです。

internal sealed class MyView
{
    private MyForm Form { get; }

    public string SetText(string text)
    {
        // MyForm に配置してるテキストボックスのテキストを変更する。
        // MyTextBox フィールドのアクセス指定子を internal にしておけば、このように別のクラスから触れる。
        Form.MyTextBox.Text = text;
    }
}

WPF の MVVM

WPF のビューモデルの実装は本当に悩ましい のですが、私が試した中で一番良かったのは、状態をイミュータブルなデータ構造として定義して、その差分を Rx.NET + ReactiveProperty に注ぎ込む手法です。

TODO リストを例にとります。次のようにモデルはイミュータブルにします。(IReadOnlyList は厳密にはイミュータブルではありませんが、普通に使えばイミュータブルなのでよしとします。)

// entities/TodoList.cs

internal sealed class TodoItem
{
    public long ItemId { get; }
    public string Text { get; }
    public bool IsDone { get; }
}

internal sealed class TodoList
{
    public IReadOnlyList<TodoItem> Items { get; }
}

モデルの差分は直和で定義してもいいですし、めんどくさければ関数でもいいと思います。

internal delegate TodoList TodoListDelta(TodoList model);

MVVM なので似たようなビューモデルを書きます。ReactiveProperty や ObservableCollection などを使った、WPF 用の実装です。

internal sealed class TodoItemViewModel
{
    public long ItemId { get; }
    public ReactivePropertySlim<string> Text { get; }
    public ReactivePropertySlim<bool> IsDone { get; }
}

internal sealed class TodoListViewModel
{
    public ObservableCollection<TodoItemViewModel> Items { get; }
}

状態を1個の ReactiveProperty に入れておき、これを購読するだけでモデルの変更をすべて検出できるようにします。

internal sealed class MainPageViewModel
{
    public ReactivePropertySlim<TodoListModel> Store { get; }
}

モデルの変更を購読して、ViewModel を更新します。Rx を使うと Store.Select().ToReadOnlyProperty() とシンプルに書けて嬉しいです。

注意点はコレクションである Items の更新が特殊なことです。Rx そのままだとできない気がするので、2つのリストを比較して差分アルゴリズムにより挿入・削除のリストを構築し、実行する、という実装にします。すべての要素にユニークなキーがついているリストの差分をとるのはそれほど難しくないです。(差分を関数ではなく直和で表現しておけばこれは回避できます。)

注意点として、更新操作を実装する際に、変更点の生じない操作に対して同一のモデルを返すようにすることです。そうしないと状態の変更時に起こしたイベントがさらに状態を更新したとき、ループがいつまで経っても終わりません。

最後に

このような胡乱な内容の記事を書くことに異論ある人もいるかもしれません。どんなに間違ったことでも頭だけで考えていると世紀の大発明のような気がしてしまうので、ときどき文字に起こして人に見られる状況下におくのは重要だと考えています。本稿の記事も、書くにつれて自信が減っていくのですが、まあしばらくこのアーキテクチャをやっていて問題は起こってないので大丈夫でしょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MSSQLのデータをコマンドラインから操作してみる

はじめに

前回はSQLiteのデータをコマンドラインから操作してみましたが、今回はMSSQLのデータをコマンドラインから操作してみます。
https://qiita.com/namikitakeo/items/5605ef2eb56cc34f352d

実行環境

下記バージョンで動作確認しています。
- MacOS
- .NET Core 3.1

% dotnet --version
3.1.101

学習方針

コマンドプロンプトから実行する事で、Windows、Linuxにおいてもそのままできると思います。

% mkdir dbtool
% cd dbtool
% dotnet new console

まずは実行してみます。

% dotnet run
Hello World!

必要なパッケージをインストールします。

% dotnet add package System.Data.SqlClient

以下のモデルを操作します。
http://www.wings.msn.to/index.php/-/A-03/978-4-7980-4179-7/

以下のような使い方を想定しています。

% dbtool

Usage: dbtool -options [file path]

options
    -e          export user from MSSQL
    -i          import user to MSSQL
    -d          delete user from MSSQL
    -v          display version
    -h          display help message

% dbtool -v

MSSQL module: version 202002

% dbtool -e users.txt

% cat users.txt
Id      Name    Email   Birth   Married Memo
1       テスト太郎              2001-01-01 01:01:01     1       テスト1
2       テスト次郎              2002-02-02 02:02:02     0       テスト2
dbtool.conf
CONNECTION  Data Source=localhost;Initial Catalog=Sample;User Id=sa;Password=Password#1;
ENCODING    utf-8
DELIMITER   tab
COLUMNS Id,Name,Email,Birth,Married,Memo
TABLE   Members
Program.cs
using System;
using System.IO;
using System.Text;
using System.Data.SqlClient;

class Program
{
        static string CONNECTION;
        static string ENCODING;
        static string DELIMITER;
        static string COLUMNS;
        static string TABLE;
        static void Main(string[] args)
        {
            if (args.Length<1 || args.Length>2) {
                help();
                return;
            }
            if (args.Length==1) {
                switch(args[0])
                {
                    case "-v": version();break;
                    default: help();break;
                }
                return;
           }
            using (StreamReader sr = new StreamReader(new FileStream("dbtool.conf", FileMode.Open), Encoding.GetEncoding("utf-8")))
            {
                while(sr.Peek() >0){
                    String line = sr.ReadLine();
                    string[] values =  line.Split('\t');
                    switch(values[0])
                    {
                        case "CONNECTION":CONNECTION=values[1];break;
                        case "ENCODING":ENCODING=values[1];break;
                        case "DELIMITER":DELIMITER=values[1];break;
                        case "COLUMNS":COLUMNS=values[1];break;
                        case "TABLE":TABLE=values[1];break;
                    }
                    if (DELIMITER=="tab") DELIMITER="\t";
                }
            }
           switch(args[0])
           {
                case "-i": import(args[1]);break;
                case "-e": export(args[1]);break;
                case "-d": delete(args[1]);break;
                default: help();break;
           }
           return;
        }
        static void import(string param)
        {
            string sqlcmd;
            using(SqlConnection cn = new SqlConnection())
            {
                cn.ConnectionString = CONNECTION;
                cn.Open();
                Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
                using (StreamReader sr = new StreamReader(new FileStream(@param, FileMode.Open), Encoding.GetEncoding(ENCODING)))
                {
                    while(sr.Peek() >0){
                        String line = sr.ReadLine();
                        string[] values =  line.Split(DELIMITER);
                        string[] columns =  COLUMNS.Split(',');
                        if (values[0]==columns[0]) {
                            continue;
                        } else {
                            sqlcmd  = "MERGE "+TABLE+" USING (SELECT distinct '"+values[0]+"' AS "+columns[0]+" FROM "+TABLE+") TAB_B ON "+TABLE+"."+columns[0]+"=TAB_B."+columns[0]+" WHEN MATCHED THEN UPDATE SET ";
                            for(int i=1; i<columns.Length; i++) {
                                if (i>1) sqlcmd += ",";
                                sqlcmd += TABLE+"."+columns[i]+"=";
                                if (values[i].Length == 0) sqlcmd+="NULL";
                                else sqlcmd += "'"+values[i]+"'";
                            }

                            sqlcmd  += " WHEN NOT MATCHED THEN INSERT (" + COLUMNS + ") VALUES (";
                            for(int i=0; i<columns.Length; i++) {
                                if (i>0) sqlcmd += ",";
                                if (values[i].Length == 0) sqlcmd+="NULL";
                                else sqlcmd += "'"+values[i]+"'";
                            }
                            sqlcmd += ");";
                        }

                        using (SqlTransaction trans = cn.BeginTransaction())
                        {
                            SqlCommand cmd = cn.CreateCommand();
                            cmd.Transaction = trans;
                            cmd.CommandText = sqlcmd;
                            cmd.ExecuteNonQuery();
                            trans.Commit();
                        }
                    }
                }
                cn.Close();
            }
            return;
        }
        static void export(string param)
        {
            using(SqlConnection cn = new SqlConnection())
            {
                cn.ConnectionString = CONNECTION;
                cn.Open();
                using (var cmd = cn.CreateCommand())
                {
                    cmd.CommandText = "SELECT "
                                   + COLUMNS
                    + " FROM "+TABLE;
                    using (SqlDataReader reader = cmd.ExecuteReader())
                    {
                        string[] columns =  COLUMNS.Split(',');
                        string message = "";
                        for(int i=0; i<columns.Length; i++) {
                            if (i > 0) message+=DELIMITER;
                            message += columns[i];
                        }
                        message += "\r\n";
                        while (reader.Read())
                        {
                            for(int i=0; i<columns.Length; i++) {
                                if (i > 0) message+=DELIMITER+reader[columns[i]].ToString();
                            }
                            message += "\r\n";
                        }
                        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
                        File.WriteAllText(@param, message, Encoding.GetEncoding(ENCODING));
                    }
                    cn.Close();
                }
            }
            return;
        }
        static void delete(string param)
        {
            using(SqlConnection cn = new SqlConnection())
            {
            cn.ConnectionString = CONNECTION;
            cn.Open();
            Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
            using (StreamReader sr = new StreamReader(new FileStream(@param, FileMode.Open), Encoding.GetEncoding(ENCODING)))
            {
                while(sr.Peek() >0){
                    string[] columns =  COLUMNS.Split(',');
                    string sqlcmd = "DELETE FROM "+TABLE+" WHERE "+columns[0]+" = '" + sr.ReadLine()+"'";
                    using (SqlTransaction trans = cn.BeginTransaction())
                    {
                        SqlCommand cmd = cn.CreateCommand();
                        cmd.Transaction = trans;
                        cmd.CommandText = sqlcmd;
                        cmd.ExecuteNonQuery();
                        trans.Commit();
                    }
                }
            }
            cn.Close();
            }
            return;
        }
        static void version()
        {
            Console.WriteLine("\nMSSQL module: version 202002\n");
            return;
        }
        static void help()
        {

            Console.WriteLine("\nUsage: dbtool -options [file path]\n");

            Console.WriteLine("options");
            Console.WriteLine("    -e          export user from MSSQL");
            Console.WriteLine("    -i          import user to MSSQL");
            Console.WriteLine("    -d          delete user from MSSQL");
            Console.WriteLine("    -v          display version");
            Console.WriteLine("    -h          display help message\n");
            return;
        }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

unityでCSVからステージを作成するエディター拡張

Unityエディター拡張でステージを作ってみる

今回はこのように3Dでステージを展開できるエディター拡張の紹介です
2019.2のバージョンでは動作確認してあります。
キャプチャ.JPG

今回のコードはこちらになります
https://gist.github.com/rx1242/c574f5b9289293a5fce5984c59fe979a

CSV読み込みについて

読み込みはReadCsvメソッドで行っていて
このメソッドは、読み込みボタンを押すと呼ばれます。
1. EditorUtility.OpenFilePanelを使いエクスプローラーで直接CSVファイルを指定して、パスを取得してきます。
2. 取得したパスを使い、StreamReaderでCSVをstringに変換します。
3. stringをカンマごとに分割して、分割データの配列から行列の数値を取得。
4. 二十配列に番地ごとのデータを入力していきます

マップチップデータ

これは簡単
各マップチップのデータをスクリプタブルオブジェクトで作成したクラスに設定するだけ!

ステージ展開

展開はApplyメソッドで行っています。

二重for文で行列ごとのデータと一致する番号のマップチップをInstantiateしていきます。

この時xyのポジションを行列と合うように移動します。

キャンセル処理

キャンセルはRollbackメソッドで行っています。
生成したステージを削除しているだけです。

工夫したポイント

そもそもの制作目的が15人チームでストラテジーゲームを作るときに、ステージの作成の高速化というのがあります。
複数人で使用されるだろうという前提があります。
ここで問題なのがファイル構造の問題で、人によってCSVを保存する場所が違うと予想。

EditorUtility.OpenFilePanelを使い直接保存場所からパスを取得できるようにしています。
これで使用するときにAssetsフォルダー内にCSVが無くてもOK!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む