C# Attributeを利用した、お手軽！CSVの入出力クラスを作ろう！

お久しぶりです.
[株式会社グレンジ](https://www.grenge.co.jp/)でエンジニアマネージャー兼クライアントエンジニアのmesshiです.
(CyberAgentグループ、ゲーム事業部の子会社です)

さて、今年もAdventCalendarの時期がやってきたので、ひょっこり投稿しておこうかなと思います.

# まえおき
今回取り上げる記事は、次のようなニーズがある方に役立つかと思います.

・プロダクトへ入れるソースコードは最低限にしたい (ThirdParty製を入れたくない)
・入出力部分のメンテナンスはしたくない
・デバッグ用のコードなので、多少パフォーマンス悪くても良い

私の利用用途は少し特殊で、ゲーム事業部の各子会社のチューニングを協力させてもらう事が多いのですが
チューニングのイテレーションをガンガン回すために、次のようなワークフローを作ります.
「①実機計測」→「②計測結果をCsvに書き出し」→「③Csvを送信」→「④データとして取り込み可視化」

その際、Csvに関する部分に関して、上記のようなニーズがありました.

では、前置きが長くなりましたが、早速作っていきましょう！

# Attributeを定義する
AttributeにCsvの入出力に必要なメタ情報を持たせます.
ヘッダー名、ヘッダーの順番さえあれば事足りるでしょう.
それを定義したクラスを生成します.

```SimapleCsvAttribute.cs
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class SimpleCsvAttribute : Attribute
{
	#region variable

	/// 列順
	private int _order;

	#endregion

	#region Property

	/// 列順
	public int Order => _order;
	/// 名前
	public string Name { get; set; }

	#endregion

	#region method

	public SimpleCsvAttribute(int order) {
		_order = order;
	}

	#endregion
}
```

AttributeUsageは、Attributeを付与できる対象のことです.
今回はProperty属性をしています

AllowMultipleは複数のAttributeを設定できるかどうかです
デフォルトfalseなので指定しなくてもOKです

# データを定義する
プロパティでCsvに書き出すデータを定義します.

```ProfileData.cs
/// <summary>
/// ProfileData
/// </summary>
public class ProfileData
{
	[SimpleCsv(1)]
	public string Name { get; set; }
	[SimpleCsv(2, Name = "処理時間")]
	public int Time { get; set; }
}
```

[SimpleCsv]のAttributeを付けなければ書き出しが行われません.
Nameの指定をしなかった場合は、プロパティの変数名をそのまま使用します


# 書き込みクラスを定義する
コンストラクタでGenericに指定されたクラスから、Attributeの情報を抜き出し、メンバ変数のリストに格納します.
あとは、そのAttributeの情報からデータを書き込むだけです.


```SimpleCsvWriter.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Linq;
using System.Reflection;
using System.Text;

/// <summary>
/// SimpleCsvWriter
/// </summary>
public class SimpleCsvWriter<T> : IDisposable where T : class, new()
{
	#region define

	/// 区切り文字
	private const string DELIMITER = ",";

	/// 引用符の正規表現
	private const string QUOTE_REGEX = "[\"\\r\\n,]";
	
	/// 属性プロパティ情報
	public class AttributePropertyInfo
	{
		public PropertyInfo Property;
		public SimpleCsvAttribute CsvAttribute;
	}

	/// 値取得のRegex
	private static readonly Regex VALUE_REGEX = new Regex(QUOTE_REGEX);

	/// ファイルのエンコードタイプ
	private static readonly Encoding FILE_ENCODING = Encoding.UTF8;

	#endregion
	
	#region variable

	/// StreamWriter
	private StreamWriter _writer;

	/// 属性の情報リスト
	private List<AttributePropertyInfo> _attirbuteInfos;
	
	/// データリスト
	private T[] _records;

	#endregion
	
	#region method

	/// <summary>
	/// コンストラクタ
	/// </summary>
	public SimpleCsvWriter(string directory, string fileName, T[] records) {
		if (!Directory.Exists(directory)) {
			Directory.CreateDirectory(directory);
		}
		
		_records = records;
		var filePath = directory + Path.DirectorySeparatorChar + fileName + ".csv";
		_writer = new StreamWriter(filePath, false, FILE_ENCODING);

		var targetType = GetType().GetGenericArguments()[0];
		_attirbuteInfos = targetType
			.GetProperties()
			.Select(x => new AttributePropertyInfo() {
				Property = x,
				CsvAttribute = x.GetCustomAttributes(typeof(SimpleCsvAttribute), false).FirstOrDefault() as SimpleCsvAttribute
			})
			.Where(x => x.CsvAttribute != null)
			.OrderBy(x => x.CsvAttribute.Order)
			.ToList();
	}

	/// <summary>
	/// 書き込む
	/// </summary>
	public void Write() {
		WriteHeader();
		for (int i = 0; i < _records.Length; i++) {
			WriteLine(_records[i]);
		}
	}

	/// <summary>
	/// ヘッダーを書き込む
	/// </summary>
	private void WriteHeader() {
		var headers = _attirbuteInfos.Select(x => x.CsvAttribute.Name ?? x.Property.Name)
			.Select(x => Quote(x))
			.ToArray();
		_writer.WriteLine(string.Join(DELIMITER, headers));
	}

	/// <summary>
	/// 1行書き込む
	/// </summary>
	private void WriteLine(T record) {
		var values = _attirbuteInfos.Select(x => x.Property.GetValue(record))
			.Select(x => Quote(x))
			.ToArray();

		_writer.WriteLine(string.Join(DELIMITER, values));
	}
	
	/// <summary>
	/// 引用符を変換する (コンマやダブルクォーテーションなど)
	/// </summary>
	private string Quote(object value) {
		string target = value != null ? value.ToString() : "";
		if (VALUE_REGEX.Match(target).Success)
		{
			return "\"" + target.Replace("\"", "\"\"") + "\"";
		}
		else
		{
			return target;
		}
	}

	/// <summary>
	/// 破棄処理
	/// </summary>
	public void Dispose() {
		_writer.Dispose();
	}

	#endregion
}
```

# 読み込みクラスを定義する
書き込みとほぼ同じ要領で行います

```SimpleCsvReader.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Linq;
using System.Reflection;
using System.Text;

/// <summary>
/// SimpleCsvReader
/// </summary>
public class SimpleCsvReader<T> : IDisposable where T : class, new()
{
	#region define
	
	/// 引用符
	private const string QUOTE = "\"";
	/// 改行正規表現
	private const string NEW_LINE_PATTERN = "(?:\x0D\x0A|[\x0D\x0A])?$";
	/// 区切り正規表現
	private const string DELIMITER_PATTERN = "(\"[^\"]*(?:\"\"[^\"]*)*\"|[^,]*),";

	/// 属性プロパティ情報
	public class AttributePropertyInfo
	{
		public PropertyInfo Property;
		public SimpleCsvAttribute CsvAttribute;
	}

	/// 引用符の正規表現
	private static readonly Regex QUOTE_REGEX = new Regex(QUOTE);
	/// 改行コードの正規表現
	private static readonly Regex NEW_LINE_REGEX = new Regex(NEW_LINE_PATTERN, RegexOptions.Singleline);
	/// 区切り文字の正規表現
	private static readonly Regex DELIMITER_REGEX = new Regex(DELIMITER_PATTERN);

	/// ファイルのエンコードタイプ
	private static readonly Encoding FILE_ENCODING = Encoding.UTF8;

	#endregion
	
	#region variable

	/// StreamReader
	private StreamReader _reader;

	/// 属性の情報リスト
	private List<AttributePropertyInfo> _attirbuteInfos;
	
	/// データリスト
	private T[] _records;


	#endregion
	
	#region method

	/// <summary>
	/// コンストラクタ
	/// </summary>
	public SimpleCsvReader(string directory, string fileName) {
		if (!Directory.Exists(directory)) {
			Directory.CreateDirectory(directory);
		}

		var filePath = directory + Path.DirectorySeparatorChar + fileName + ".csv";
		try {
			_reader = new StreamReader(filePath, FILE_ENCODING);
		} catch (Exception ex) {
			throw ex;
		}
		
		var targetType = GetType().GetGenericArguments()[0];
		_attirbuteInfos = targetType
			.GetProperties()
			.Select(x => new AttributePropertyInfo() {
				Property = x,
				CsvAttribute = x.GetCustomAttributes(typeof(SimpleCsvAttribute), false).FirstOrDefault() as SimpleCsvAttribute
			})
			.Where(x => x.CsvAttribute != null)
			.OrderBy(x => x.CsvAttribute.Order)
			.ToList();
	}

	/// <summary>
	/// 読み込み
	/// </summary>
	public T[] Read(bool hasHeader = true) {
		if (_reader == null) {
			return null;
		}
		
		if (hasHeader) {
			// ヘッダー分だけ進める
			_reader.ReadLine();
		}
		
		List<T> dataList = new List<T>();
		while (!_reader.EndOfStream) {
			var line = _reader.ReadLine();
			if (line == null) {
				break;
			}

			// 改行を考慮して行を読み込む
			while (!HasEnoughQuote(line)) {
				line += "\n" + _reader.ReadLine();
				if (_reader.EndOfStream) {
					break;
				}
			}

			// 改行コードを排除する
			line = NEW_LINE_REGEX.Replace(line, "");
			
			// 要素分解を行う
			line += ",";
			var matches = DELIMITER_REGEX.Matches(line);
			var columns = matches.Cast<Match>()
				.Select(x => Dequote(x))
				.ToArray();

			// データを作成する
			var data = new T();
			for (int i = 0; i < columns.Length; i++) {
				var attribute = _attirbuteInfos[i];
				attribute.Property.SetValue(data, Convert.ChangeType(columns[i], attribute.Property.PropertyType));
			}
			dataList.Add(data);
		}

		return dataList.ToArray();
	}

	/// <summary>
	/// バイト配列で読み込む
	/// </summary>
	public byte[] ReadBytes() {
		if (_reader == null) {
			return null;
		}

		byte[] readBytes = null;
		using (MemoryStream memoryStream = new MemoryStream()) {
			_reader.BaseStream.CopyTo(memoryStream);
			readBytes = memoryStream.ToArray();
		}

		return readBytes;
	}
	
	/// <summary>
	/// 引用符が十分であるかどうか
	/// </summary>
	private bool HasEnoughQuote(string line) {
		return (QUOTE_REGEX.Matches(line).Count % 2) == 0;
	}
	
	/// <summary>
	/// 引用符を変換する
	/// </summary>
	private string Dequote(Match match)
	{
		var s = match.Groups[1].Value;
		var quoted = Regex.Match(s, "^\"(.*)\"$", RegexOptions.Singleline);

		if (quoted.Success)
		{
			return quoted.Groups[1].Value.Replace("\"\"", "\"");
		}
		else
		{
			return s;
		}
	}

	/// <summary>
	/// 破棄処理
	/// </summary>
	public void Dispose() {
		if (_reader != null) {
			_reader.Dispose();
		}
	}

	#endregion
}

```

ReadByteのメソッドは不要ですが、Csvをポストする際にByte配列にする必要があったので付け加えているだけです.
以上で、必要なクラスの定義は終了です.

# 利用例
では、実際に使ってみましょう.
Attributeを利用したデータクラスを作成し、SimpleCsvWriterにGenricで渡すだけです.

```ProfileTest.cs
using System.IO;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ProfileTest
/// </summary>
public class ProfileTest : MonoBehaviour
{
    #region define
    
    /// <summary>
    /// ProfileData
    /// </summary>
    public class ProfileData
    {
        [SimpleCsv(1)]
        public string Name { get; set; }
        [SimpleCsv(2, Name = "処理時間")]
        public int Time { get; set; }
    }

    #endregion
    
    #region method
    
    /// <summary>
    /// データを書き込む
    /// </summary>
    public void Write() {
        // 適当なテスト用のデータ作成
        var list = new List<ProfileData>(10);
        for (int i = 0; i < 10; i++) {
            list.Add(new ProfileData() {
                Name = i.ToString(),
                Time = (i * i)
            });
        }
		
        // 書き込み部分
        var directory = Application.persistentDataPath + Path.DirectorySeparatorChar + "Exports";
        using (var writer = new SimpleCsvWriter<ProfileData>(directory, "test", list.ToArray())) {
            writer.Write();
        }
    }

    /// <summary>
    /// データを読み込む
    /// </summary>
    public ProfileData[] Read() {
        ProfileData[] profiles = null;
        var directory = Application.persistentDataPath + Path.DirectorySeparatorChar + "Exports";
        using (var reader = new SimpleCsvReader<ProfileData>(directory, "test")) {
            profiles = reader.Read();
        }

        return profiles;
    }
    
    #region unity_script
    
    /// <summary>
    /// 開始処理
    /// </summary>
    private void Start() {
        // データ書き込み
        Write();
        
        // データ読み込み
        var profiles = Read();
        foreach (var profile in profiles) {
            Debug.Log(profile.Name + " = " + profile.Time);
        }
    }
    
    #endregion
    
    #endregion
}

```

# 最後に
Csvの入出力クラスはCsvHelperなどのThird Party製のものや、その他沢山の紹介記事がありますが、
痒いところに手が届かずこのようなクラスを作成することになりました.
この記事が誰かの一助になれば幸いです.

少し早いですが、今年もお疲れ様でした.
来年も頑張っていきましょう！