Edited at

【Unity/JSON】ジェネリックと継承でORMを作ってみる


サマリー

ジェネリックの理解を兼ねてJSONをつかった2次元表形式データ1を保存する仕組みを作ってみました。

成果物に求めるポイントとして「手で書くコードをなるべく減らせる」というのを目標にしてます。

ほぼ俺俺フレームワークなのでアレですが、もし突っ込んでくださる方いたらどうぞよろしくお願いします…!

地味に「JsonUtilityで保存できないList<T>型の保存ができる」とか「SQL使わなくても満足にデータ操作ができる」みたいな機能付いてるので、Unity初心者でセーブ作りたい方も是非!

(Unityの機能っつっても使ってるのはJsonUtilityとSerializeくらいなので、C#使ってる人ならJson⇔自作クラスの変換さえできればUnityつかわなくても導入できるかも)


成果物に求めること

・SQLより補完の利くLINQを使いたい(手でコード書きたくない)

・JSONは型安全ではないのでEntityクラスにパースしてして補完を使いたい(手でコード書きたくない)

・Daoパターンで実装したいけど手でコード書きたくない

・とにかく手でコード書きたくない


どういう仕様にするか

・保存されるJSONデータの構造は2次元表形式 (キー値 : ID)

・EntityとDaoの命名規則は、テーブル名がHogeならHogeEntity,HogeDaoとする

・1つのテーブルにつきEntityとDaoがそれぞれ一つ必要

・テーブルとJSONファイルは1対1に対応

・テーブルには必ずIDというカラムがある


こんな機能も

・SQL不要でデータ操作

・idでの検索みたいな一般的なメソッドは利用側で実装しなくても使える(今回の目標の一つ)

・JsonUtilityで保存できないList<T>型の保存ができる。List<T>型でデータを取得するので型安全。


クラスの切り分け

基底側となるクラス*6(idでの検索など、全テーブルに共通の操作を記述する)

利用側となるクラス*2(日時での検索など、テーブルごとに固有の操作を記述する)

計8クラスで構成します。

ちょっとファイルが多いですが一個一個の記述量は平均20~30行くらいです。


基底側のクラス

*PersistentConfig.cs

接続先やパスワードなどを記述するConfファイルです。

テスト用データの保存先とリリース用データの保存先を分けておきましょう。


PersistenceConfig.cs


namespace Persistence
{
public class PersistenceConfig
{
// 本番環境 : ../Data/
// テスト環境 : ../DataDevelop/
public const string dataFilePath = "../DataDevelop/";
}
}

*IDataAccessor.cs

データの読み出しと保存のinterfaceです。

Unityだとマルチプラットフォーム対応は頻繁に出てくるので、保存方法を変更できるようにinterfaceを切ります。


IDataAccessor.cs

namespace Persistence

{
interface IDataAccessor
{
List<T> Load<T>() where T : IEntity;
void Save<T>(List<T> entities) where T : IEntity;
}
}

*JsonDateAccessor.cs

IDataAccessorの具象クラスです。指定したパスにJSONファイルを保存したり読み出したりします。

このクラスでJSONをEntityにパースし、型安全にしています。(詳しくは後述)これで補完が使えるようになります。

同時にテーブルと同様の構造を持つListを用意することで、LINQが使えるようになります。

これだけでも文字列ベースのSQLを使うよりだいぶ入力ミスもなくなって楽かなと思います。

このコードでは暗号化のために有料アセットのEasySave3を噛ませていますが、普通に保存してもOKです。


JsonDataAccessor.cs

using System.Collections;

using System.Collections.Generic;
using UnityEngine;
using System.Linq;

namespace Persistence
{
public class JsonDataAccessor : IDataAccessor
{
public List<T> IDataAccessor.Load<T>()
{
// EasySaveは使わなくてもOK.JSONファイルにアクセスできれば良い.
var jsonStr = ES3.Load<string>(GetFileName<T>(), GetFilePath<T>());
var entities = JsonUtility.FromJson<Entities<T>>(jsonStr);
Debug.Log("JSON string is loaded : " + jsonStr);
return entities.field;
}

public void IDataAccessor.Save<T>(List<T> entities)
{
var jsonFileName = GetFileName<T>();
var orderedEntities = entities.OrderBy(element => element.id).ToList();
var jsonStr = JsonUtility.ToJson(new Entities<T>(orderedEntities));
// EasySaveは使わなくてもOK.JSONファイルにアクセスできれば良い.
ES3.Save<string>(GetFileName<T>(), jsonStr, GetFilePath<T>());
Debug.Log("JSON string is saved : " + jsonStr);
}

// ファイル名+Entity"というクラスを使って保存する
private string GetFileName<T>()
{
string entityClassName = typeof(T).Name;
var jsonFileName = entityClassName.Replace("Entity", "");
return jsonFileName;
}

private string GetFilePath<T>()
{
return PersistenceConfig.dataFilePath + GetFileName<T>() + ".json";
}
}
}


*Entities.cs

JsonUtilityは直接List<T>の保存はできません。

ただし、フィールドとしてList<T>を持っているクラスは保存できるという仕様になっています。

なのでひと工夫して、List保存用にListフィールドを持ったクラスを作ります。

(Listの保存ができるように対応してくれたらいいんですが…)

また、JsonUtilityを使う際は、保存する変数にSerializableを付ける必要があるので注意です。


Entities.cs

using System.Collections;

using System.Collections.Generic;
using UnityEngine;
using System;

namespace Persistence
{
[Serializable]
public class Entities<T> where T : IEntity
{
[SerializeField]
public List<T> field;

public Entities(List<T> entities)
{
this.field = entities;
}
}
}


*IEntity

データベースの行に対応するクラスをEntityと呼びます。DBとデータを受け渡しする際に使うクラスです。

今回は全てのEntityの基底クラスを定義し、継承することによってEntityにidを持つことを強制します。

後述しますが、こうしておくと便利な方法が使えるんです。


IEntity.cs

using System.Collections;

using System.Collections.Generic;
using UnityEngine;
using System;

namespace Persistence
{
[Serializable]
public class IEntity
{
public int id;
}
}


*BaseDao

このBaseDaoが今回のキモです。

DaoはSelectやUpdateなどのデータを処理するメソッドを引き受けてくれるクラスです。2

Daoを使う上での願いとして、できればIdによる検索などの「ほぼ必ず使う一般的なメソッド」は共通化しておき、テーブルごとに書かなくてもいいようにしたいというのがあると思います。(実際これが標準でついてるORMもありますよね)

そこでDao基底クラスに一般的なメソッドを押し込むことを考えるわけですが、データの取得にはEntityを用いるため、基底側で個別テーブルに属するEntityの処理を共通化しようとなると、ジェネリックメソッドであることが邪魔をして個別クラスの情報を入れることができません(クラスが違うので代入できずコンパイルエラーになる)

じゃあジェネリックをなくしてしまうとどうなるかというと、今度はBaseDaoをテーブルの数だけ書くことになってしまい、記述量を減らすために基底クラスを用意したはずが本末転倒です。

ならばということで生のJSONデータを取得すると、今度は型安全性が崩れてIDEの補完が効かなくなります。

ここで先ほど定義したIEntityが活きてきます。

where T : IEntityを付けてやることによって"Entityはidを持っている"という情報が静的に付加されるので、ジェネリックメソッドでもidを使った処理が書けるようになるんですね。

これによって有効に継承関係を利用することができ、SelectById等の一般的なメソッドを基底に押し込むことが可能になります。

従って、個別のDaoでは共通メソッドは書かなくてもよくなり、利用側の実装から一般処理を切り離すことができます。

選択、削除、挿入を基底で共通化したのが以下のコードです。


BaseDao.cs

using System.Collections;

using System.Collections.Generic;
using System.Linq;

namespace Persistence
{
public class BaseDao<T> where T : IEntity
{
public List<T> SelectById(int id)
{
IDataAccessor dataAccessor = new JsonDataAccessor();
var data = dataAccessor.Load<T>().Where(element => element.id == id).ToList();
return data;
}

public List<T> SelectAll()
{
IDataAccessor dataAccessor = new JsonDataAccessor();
var data = dataAccessor.Load<T>();
return data;
}

public bool Insert(T entity)
{
IDataAccessor dataAccessor = new JsonDataAccessor();
var data = dataAccessor.Load<T>();
data.Add(entity);
dataAccessor.Save(data);
return true;
}

public bool DeleteById(int id)
{
IDataAccessor dataAccessor = new JsonDataAccessor();
var data = dataAccessor.Load<T>();
data.RemoveAll(entity => entity.id == id);
dataAccessor.Save(data);
return true;
}
}
}


これで基底側が完成しました。

以上のコードをプロジェクトに入れてやることで、スピーディーに個別処理を書くことができます。

これらのコードはすべて再利用を前提に作られており、以下で説明する利用側のコードを変えたからと言って変更する必要はありません。


利用側のクラスの実装

以上で作ったクラスはそのままでは使えません。

言ってみれば今までのクラスはテーブル一般についての処理だけを書いたようなものです。

実際に操作を行うには、具体的にどのテーブルを扱うかによって個別に処理が必要です。

そこで、さらに「Entity(カラムの情報を持つクラス)」と「Dao(操作の情報を持つクラス)」の2つのクラスを追加します。

下記では、Itemという名前のテーブルを例に説明します。

Itemはidとnameを持つテーブルです。

*ItemEntity

テーブルを表すEntityです。個別のテーブルの情報を静的に持ちます。

idとnameをもつテーブル"Item"の場合は、Entity名を"ItemEntity"とし、ItemEntityにid以外のフィールドを持たせます。

フィールドの型はListとかDictionaryでなければ何でもJsonUtilityがパースしてくれます。(ListだったらEntitiesを使えば保存できるかと思います)

やったことないですが多分自作クラス持ってても保存できます。JSON優秀!


ItemEntity.cs

using System.Collections;

using System.Collections.Generic;
using UnityEngine;
using System;

namespace Persistence
{
[Serializable]
public class ItemEntity : IEntity
{
public string name;
}
}


*HogeDao

個別のテーブルを操作するDaoです。テーブル操作の情報を持ちます。

ジェネリックメソッドの型引数が解決されていることに注意してください。

基底に一般メソッドを押し込んでいるので、一般的な操作は書く必要がなくなってスッキリしました。

また、テーブルごとに個別に必要なメソッドはここへ書き込みます。

たとえばnameはItemにしか含まれていないため、nameを用いた検索メソッドはItemDaoに書きます。


Camerawork.cs

using System.Collections;

using System.Collections.Generic;
using UnityEngine;

namespace Persistence
{
public class ItemDao : BaseDao<ItemEntity>
{
public List<ItemEntity> SelectByName(int name)
{
IDataAccessor dataAccessor = new JsonDataAccessor();
var data = dataAccessor.Load<ItemEntity>().Where(element => element.id == id).ToList();
return data;
}
}
}



使ってみる

*PersistenceClient (利用側)

こんな感じで利用できます。


PersistenceClient.cs

using System.Collections;

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;
using Persistence;

public class PersistenceClient : MonoBehaviour
{
public InputField id;
public InputField name;

// idでItemを検索
public void SelectByItemId()
{
var data = new ItemDao().SelectById(int.Parse(id.text));
PrintResult(data);
}

// 名前でItemを検索
public void SelectByItemName()
{
var data = new ItemDao().SelectByName(int.Parse(name.text));
PrintResult(data);
}

// コンソールに検索結果を出力
private void PrintResult(List<ItemEntity> data)
{
foreach (var element in data)
{
Debug.Log(element.id + "," + element.name);// 出力例 : 100,HogeItem
}
}
}


簡単ですね。


テーブルを増やすには

テーブル名Hogeを追加したい場合、HogeDaoとHogeEntityの2つを追加してください。

命名規約を使っているため、セーブすれば勝手にHoge.jsonが生成され、読み書きできるようになります。


変更を加える


一般メソッドを増やすには

BaseDaoに処理を追加します。ほかの変更は必要ありません。


テーブルに固有なメソッドを増やすには

HogeDaoに処理を追加します。ほかの変更は必要ありません。


課題


Dictionaryの保存ができない

ハッシュアクセスのためListより検索が速いそうです。工夫すればListみたいな方法で保存が可能だとか。


パフォーマンスが低い

読み込みと書き出しの際にいちいち全件取得、全件書き込みをしているので、読み込むファイルが増えた場合、DBに比べるとパフォーマンスは劣ります。


データ側でスキーマを持っていない。

JSONなのでスキーマをデータだけで表現することが難しいです。

例えばスキーマを持つXMLで実装するなどの工夫が必要になります。


入力チェックがかからないので危険

もうこれはRDB使ったほうが早い気がします。


まとめ

長々書きましたがおかしなところもいっぱいあるかもしれません。

是非コメントで教えてください。





  1. 実態はidに紐づくEntityを保存するKey-Value Storeといった方が正しいです。 



  2. 本家DAOパターンはAbstructFactoryパターンを使っているので、これは厳密にはDAOというよりFacadeに近いです。