ファイルシステムを用いたデータリポジトリパターンを実装する

## はじめに

[前々回](https://qiita.com/mxProject/items/ad81cb580f758f2530a9)の記事で紹介したリポジトリパターンライブラリを使用したファイルシステム用リポジトリを紹介します。

エンティティのキーの値をファイル名として用いる方法と、キーファイルを用いてエンティティファイルのパスを管理する方法を実装しています。

## 公開先

https://github.com/mxProject/DataRepository

https://www.nuget.org/packages/mxProject.Data.FileRepository/

## サンプル

### シンプルなリポジトリ

エンティティのキーの値をファイル名として用いるシンプルなリポジトリです。

```console:データディレクトリのイメージ
d:\data\sampleentity
    b4124400-8d9f-4eb9-b6da-bf0128756763.json
    d3fee6e9-acf8-4600-86c4-7ef5aec3b734.json
    d65bdbab-7af8-452e-b1c0-50b1ca075948.json
```

```c#
var serializer = new SampleJsonSerializer();

// The DefaultFileManager class does not support transactions.
// If you require transactional control through TransactionScope, use the Transactional FileManager class instead.
var fileManager = new DefaultFileManager(serializer);

var dataDirectory = "d:\data\sampleentity"

// create a repository.
using var repo = new SampleEntityRepositoryUsingKeyAsFileName(dataDirectory, fileManager);

// get the entity with ID.
var entity = repo.Get(guid);

if (entity != null)
{
    Console.WriteLine($"Id:{entity.Id}, Code:{entity.Code}, Name:{entity.Name}");
}

var entity = new SampleEntity()
{
    Id = Guid.NewGuid(),
    Code = 3,
    Name = "item3"
};

// insert the entity.
repo.Insert(entity)
```

<details><summary>SampleEntityRepositoryUsingKeyAsFileName クラスの実装はこちら</summary>

```c#
/// <summary>
/// Repository for SampleEntity using the entity's key as the filename.
/// </summary>
internal class SampleEntityRepositoryUsingKeyAsFileName
    : FileRepositoryUsingKeyAsFileName<SampleEntity, Guid>, ISampleEntityRepository
{
    /// <summary>
    /// Creates a new instance.
    /// </summary>
    /// <param name="dataDirectory">The directory in which to save data files.</param>
    /// <param name="fileManager">The file manager.</param>
    internal SampleEntityRepositoryUsingKeyAsFileName(string dataDirectory, IFileManager fileManager)
        : base(dataDirectory, CreateEntityFileNaming(), fileManager, Encoding.UTF8)
    {
    }

    /// <summary>
    /// Creates the entity file naming logic.
    /// </summary>
    /// <returns>The entity file naming logic.</returns>
    private static IEntityFileNamingUsingKeyAsFileName<SampleEntity, Guid> CreateEntityFileNaming()
    {
        // ex) 350c9b44-8bd5-46af-b981-078892acfd90.json
        var fileNaming = EntityFileNaming.CreateKeyFileNameConverter(
            ".json",
            x => x.ToString(),
            x => Guid.Parse(x)
            );

        return EntityFileNaming.CreateUsingKeyAsFileName<SampleEntity, Guid>(
            x => x.ID!.Value,
            fileNaming
            );
    }
}
```

</details>

### キーファイルを用いたリポジトリ

キーファイルを用いてエンティティファイルのパスを管理するリポジトリです。

```console:データディレクトリのイメージ
d:\data\sampleentity
    \keys
        b4124400-8d9f-4eb9-b6da-bf0128756763.key
        d3fee6e9-acf8-4600-86c4-7ef5aec3b734.key
        d65bdbab-7af8-452e-b1c0-50b1ca075948.key
    \entities
        00001_item1.json
        00002_item2.json
        00003_item3.json
```

リポジトリの使用方法はキーの値をファイル名として用いるリポジトリと同じです。

```c#
var serializer = new SampleJsonSerializer();

// The DefaultFileManager class does not support transactions.
// If you require transactional control through TransactionScope, use the Transactional FileManager class instead.
var fileManager = new DefaultFileManager(serializer);

var dataDirectory = "d:\data\sampleentity"

// create a repository.
using var repo = new SampleEntityRepositoryUsingKeyFile(dataDirectory, fileManager);

// get the entity with ID.
var entity = repo.Get(guid);

if (entity != null)
{
    Console.WriteLine($"Id:{entity.Id}, Code:{entity.Code}, Name:{entity.Name}");
}

var entity = new SampleEntity()
{
    Id = Guid.NewGuid(),
    Code = 3,
    Name = "item3"
};

// insert the entity.
repo.Insert(entity)
```

<details><summary>SampleEntityRepositoryUsingKeyFile クラスの実装はこちら</summary>

```c#
/// <summary>
/// Repository for SampleEntity using key file.
/// </summary>
internal class SampleEntityRepositoryUsingKeyFile
    : FileRepositoryUsingKeyFile<SampleEntity, Guid>, ISampleEntityRepository
{
    /// <summary>
    /// Creates a new instance.
    /// </summary>
    /// <param name="dataDirectory">The data directory.</param>
    /// <param name="fileManager">The file manager.</param>
    internal SampleEntityRepositoryUsingKeyFile(string dataDirectory, IFileManager fileManager)
        : base(dataDirectory, CreateEntityFileNaming(), fileManager, Encoding.UTF8)
    {
    }

    /// <summary>
    /// Creates the entity file naming.
    /// </summary>
    /// <returns>The entity file naming.</returns>
    private static IEntityFileNamingUsingKeyFile<SampleEntity, Guid> CreateEntityFileNaming()
    {
        // ex) 350c9b44-8bd5-46af-b981-078892acfd90.key
        var keyFileNaming = EntityFileNaming.CreateKeyFileNameConverter(
            ".key",
            x => x.ToString(),
            x => Guid.Parse(x)
            );

        // ex) 00001_Entity1.json
        var entityFileNaming = EntityFileNaming.CreateEntityFileNameConverter<SampleEntity>(
            ".json",
            x => $"{x.Code:d5}_{x.Name}"
            );

        return EntityFileNaming.CreateUsingKeyFile<SampleEntity, Guid>(
            x => x.ID!.Value,
            keyFileNaming,
            entityFileNaming
            );
    }
}
```

</details>

## クラス図

### コンテキストなし

SampleEntityRepositoryUsingKeyAsFileName クラスと SampleEntityRepositoryUsingKeyFile クラスはともに ISampleEntityRepository インターフェースを実装します。作図上のバランスが崩れるため、割愛しました。

```mermaid
classDiagram
    direction BT
    class IReadDataRepository
    <<Interface>> IReadDataRepository
    class IWriteDataRepository
    <<Interface>> IWriteDataRepository
    class IFileManager
    <<Interface>> IFileManager
    class IEntityFileNamingKeyAsFileName
    <<Interface>> IEntityFileNamingKeyAsFileName
    class IEntityFileNamingUsingKeyFile
    <<Interface>> IEntityFileNamingUsingKeyFile
    class ISampleEntityRepository
    <<interface>> ISampleEntityRepository

    FileRepositoryBase ..|> IReadDataRepository
    FileRepositoryBase ..|> IWriteDataRepository
    FileRepositoryUsingKeyAsFileName --|> FileRepositoryBase
    IEntityFileNamingKeyAsFileName --* FileRepositoryUsingKeyAsFileName
    IFileManager --* FileRepositoryUsingKeyAsFileName
    FileRepositoryUsingKeyFile --|> FileRepositoryBase
    IEntityFileNamingUsingKeyFile --* FileRepositoryUsingKeyFile
    IFileManager --* FileRepositoryUsingKeyFile

    DefaultFileManager ..|> IFileManager
    TransactionalFileManager ..|> IFileManager

    SampleEntityRepositoryUsingKeyAsFileName  --|> FileRepositoryUsingKeyAsFileName
    SampleEntityRepositoryUsingKeyFile --|> FileRepositoryUsingKeyFile
```

### コンテキストあり

クラスやインターフェースの構造はコンテキストなしの場合と同じです。

```mermaid
classDiagram
    direction BT
    class IReadDataRepositoryWithContext
    <<Interface>> IReadDataRepositoryWithContext
    class IWriteDataRepositoryWithContext
    <<Interface>> IWriteDataRepositoryWithContext
    class IFileManagerWithContext
    <<Interface>> IFileManagerWithContext
    class IEntityFileNamingWithContextKeyAsFileName
    <<Interface>> IEntityFileNamingWithContextKeyAsFileName
    class IEntityFileNamingWithContextUsingKeyFile
    <<Interface>> IEntityFileNamingWithContextUsingKeyFile
    class ISampleEntityRepositoryWithContext
    <<interface>> ISampleEntityRepositoryWithContext

    FileRepositoryWithContextBase ..|> IReadDataRepositoryWithContext
    FileRepositoryWithContextBase ..|> IWriteDataRepositoryWithContext
    FileRepositoryWithContextUsingKeyAsFileName --|> FileRepositoryWithContextBase
    IEntityFileNamingWithContextKeyAsFileName --* FileRepositoryWithContextUsingKeyAsFileName
    IFileManagerWithContext --* FileRepositoryWithContextUsingKeyAsFileName
    FileRepositoryWithContextUsingKeyFile --|> FileRepositoryWithContextBase
    IEntityFileNamingWithContextUsingKeyFile --* FileRepositoryWithContextUsingKeyFile
    IFileManagerWithContext --* FileRepositoryWithContextUsingKeyFile

    DefaultFileManagerWithContext ..|> IFileManagerWithContext
    TransactionalFileManagerWithContext ..|> IFileManagerWithContext

    SampleEntityRepositoryWithContextUsingKeyAsFileName --|> FileRepositoryWithContextUsingKeyAsFileName
    SampleEntityRepositoryWithContextUsingKeyFile --|> FileRepositoryWithContextUsingKeyFile
```

コンテキストを使用するリポジトリについては、メソッドの引数にコンテキストが追加されることを除けば考え方は同じですので、説明は割愛します。


## 基底クラス

### FileRepositoryBase&lt;TEntity, TKey&gt; クラス

ファイルシステムを用いたデータリポジトリの基底実装です。エンティティやキーに対応するファイル名を決定する処理とファイルの入出力処理は抽象メソッドになっています。

```c#
/// <summary>
/// Basic implementation of a data repository using a file system.
/// </summary>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TKey">The key type.</typeparam>
public abstract class FileRepositoryBase<TEntity, TKey> : IReadDataRepository<TEntity, TKey>, IWriteDataRepository<TEntity>
{
    /// <summary>
    /// Creates a new instance.
    /// </summary>
    /// <param name="dataDirectoryPath">The directory in which to save data files.</param>
    protected FileRepositoryBase(string dataDirectoryPath)
    {
        DataDirectoryPath = PathEx.GetFullPath(dataDirectoryPath);
    }

    /// <summary>
    /// Gets the directory in which to save data files.
    /// </summary>
    public string DataDirectoryPath { get; }

    /// <inheritdoc/>
    public abstract bool UseTransactionScope { get; }

    /// <inheritdoc/>
    public void Dispose()
    {
        throw new NotImplementedException();
    }

    #region get

    /// <inheritdoc/>
    public TEntity? Get(TKey key)
    {
        var filePath = GetEntityFilePath(key);
        if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return default; }
        return LoadFromFile(filePath!);
    }

    /// <inheritdoc/>
    public IEnumerable<TEntity> GetRange(IEnumerable<TKey> keys)
    {
        foreach (var key in keys)
        {
            var entity = Get(key);
            if (entity != null) { yield return entity; }
        }
    }

    /// <inheritdoc/>
    public IEnumerable<TEntity> GetAll()
    {
        foreach (var filePath in Directory.GetFiles(DataDirectoryPath, "", SearchOption.AllDirectories))
        {
            if (TryGetEntity(filePath, out var entity)) { yield return entity; }
        }
    }

    /// <inheritdoc/>
    public IEnumerable<TKey> GetAllKeys()
    {
        foreach (var filePath in Directory.GetFiles(DataDirectoryPath, "", SearchOption.AllDirectories))
        {
            if (TryGetKey(filePath, out var key)) { yield return key; }
        }
    }

    #endregion

    #region insert

    /// <inheritdoc/>
    public int Insert(TEntity entity)
    {
        var filePath = GetEntitySaveFilePath(entity);
        SaveToFile(entity, filePath);
        return 1;
    }

    /// <inheritdoc/>
    public int InsertRange(IEnumerable<TEntity> entities)
    {
        int count = 0;
        foreach (var entity in entities)
        {
            count += Insert(entity);
        }
        return count;
    }

    #endregion

    #region update

    /// <inheritdoc/>
    public int Update(TEntity entity)
    {
        var filePath = GetEntitySaveFilePath(entity);
        SaveToFile(entity, filePath);
        return 1;
    }

    /// <inheritdoc/>
    public int UpdateRange(IEnumerable<TEntity> entities)
    {
        int count = 0;
        foreach (var entity in entities)
        {
            count += Update(entity);
        }
        return count;
    }

    #endregion

    #region delete

    /// <inheritdoc/>
    public int Delete(TEntity entity)
    {
        var filePath = GetEntitySaveFilePath(entity);
        if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { return 0; }
        DeleteFile(filePath);
        return 1;
    }

    /// <inheritdoc/>
    public int DeleteRange(IEnumerable<TEntity> entities)
    {
        int count = 0;
        foreach (var entity in entities)
        {
            count += Delete(entity);
        }
        return count;
    }

    #endregion

    /// <summary>
    /// Gets the key that corresponds to the specified file path.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    /// <param name="key">The identified Key.</param>
    /// <returns>Returns true if the key is identified, otherwise false.</returns>
    protected abstract bool TryGetKey(string filePath, out TKey key);

    /// <summary>
    /// Gets the entity that corresponds to the specified file path.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    /// <param name="entity">The deserialized entity.</param>
    /// <returns>Returns true if the entity is deserialized, otherwise false.</returns>
    protected abstract bool TryGetEntity(string filePath, out TEntity entity);

    /// <summary>
    /// Gets the file path that corresponds to the specified key.
    /// </summary>
    /// <param name="key">The key.</param>
    /// <returns>The file path.</returns>
    protected abstract string? GetEntityFilePath(TKey key);

    /// <summary>
    /// Gets the file path where the specified entity will be saved.
    /// </summary>
    /// <param name="entity">The entity.</param>
    /// <returns>The file path.</returns>
    protected abstract string GetEntitySaveFilePath(TEntity entity);

    /// <summary>
    /// Loads the entity from the specified file.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    /// <returns>The entity.</returns>
    protected abstract TEntity LoadFromFile(string filePath);

    /// <summary>
    /// Saves the specified entity to the specified file.
    /// </summary>
    /// <param name="entity">The entity.</param>
    /// <param name="filePath">The file path.</param>
    protected abstract void SaveToFile(TEntity entity, string filePath);

    /// <summary>
    /// Deletes the specified file.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    protected abstract void DeleteFile(string filePath);
}
```

## シンプルなリポジトリ

### FileRepositoryUsingKeyAsFileName&lt;TEntity, TKey&gt; クラス

エンティティのキーの値をファイル名として用いるシンプルなリポジトリです。

* FileRepositoryBase&lt;TEntity, TKey&gt; クラスを継承します。
* ファイル名を決定する処理は IEntityFileNamingUsingKeyAsFileName&lt;TEntity, TKey&gt; インターフェースを実装したオブジェクトに委譲します。
* ファイル入出力処理は IFileManager インターフェースを実装したオブジェクトに委譲します。

```c#
public class FileRepositoryUsingKeyAsFileName<TEntity, TKey> 
    : FileRepositoryBase<TEntity, TKey>
{
    public FileRepositoryUsingKeyAsFileName(
        string dataDirectoryPath,
        IEntityFileNamingUsingKeyAsFileName<TEntity, TKey> fileNaming,
        IFileManager fileManager,
        Encoding encoding
        )
    : base(dataDirectoryPath)
    {
        m_EntityFileNaming = fileNaming;
        m_FileManager = fileManager;
        m_Encoding = encoding;
    }

    // omission
}
```

### IEntityFileNamingUsingKeyAsFileName&lt;TEntity, TKey&gt; インターフェース

エンティティのキーの値からファイル名を決定するために必要な機能を提供するインターフェースです。

* 前述のサンプルの SampleEntityRepositoryUsingKeyAsFileName クラスでは、CreateEntityFileNaming メソッドでこのインターフェースを実装するオブジェクトを生成しています。

```c#
/// <summary>
/// Provides the functionality needed to use an entity's key as the filename.
/// </summary>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TKey">The key type.</typeparam>
public interface IEntityFileNamingUsingKeyAsFileName<TEntity, TKey>
{
    /// <summary>
    /// Gets the key corresponding to the specified entity file name.
    /// </summary>
    /// <param name="entityFilePath">The entity file name.</param>
    /// <returns>The key.</returns>
    TKey? GetKeyFromFileName(string entityFilePath);

    /// <summary>
    /// Gets the entity file name corresponding to the specified key.
    /// </summary>
    /// <param name="key">The key.</param>
    /// <returns>The entity file name.</returns>
    string GetEntityFileName(TKey key);

    /// <summary>
    /// Gets the key for the specified entity.
    /// </summary>
    /// <param name="entity">The entity.</param>
    /// <returns>The key.</returns>
    TKey GetKey(TEntity entity);
}
```

### IFileManager インターフェース

ファイル入出力に必要な機能を提供するインターフェースです。パッケージには以下の二つの実装クラスが含まれています。

* mxProject.Data.FileRepository パッケージに含まれる DefaultFileManager クラスは、.NET 標準ライブラリの System.IO 名前空間のクラスを用いています。

* mxProject.Data.FileRepository.TxFileManager パッケージに含まれる TransactionalFileManager クラスは、[TxFileManager](https://github.com/chinhdo/txFileManager) を用いて TransactionScope によるトランザクション制御下でファイル入出力を行います。
  * RDB で実現されているような完全なトランザクション制御ではありません。

```c#
/// <summary>
/// Provides the functionality required to read and write files.
/// </summary>
public interface IFileManager
{
    /// <summary>
    /// Gets a value that indicates whether to use ambient transactions using TransactionScope.
    /// </summary>
    bool UseTransactionScope { get; }

    /// <summary>
    /// Loads the entity from the specified file.
    /// </summary>
    /// <typeparam name="TEntity">The entity type.</typeparam>
    /// <param name="filePath">The file path.</param>
    /// <param name="encoding">The encoding.</param>
    /// <returns>The entity.</returns>
    TEntity LoadFromFile<TEntity>(string filePath, Encoding encoding);

    /// <summary>
    /// Saves the specified entity to the specified file.
    /// </summary>
    /// <typeparam name="TEntity">The entity type.</typeparam>
    /// <param name="entity">The entity.</param>
    /// <param name="filePath">The file path.</param>
    /// <param name="encoding">The encoding.</param>
    void SaveToFile<TEntity>(TEntity entity, string filePath, Encoding encoding);

    /// <summary>
    /// Deletes the specified file.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    void DeleteFile(string filePath);

    /// <summary>
    /// Writes the specified string to the specified file.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    /// <param name="contents">The string.</param>
    /// <param name="encoding">The encoding.</param>
    void WriteAllText(string filePath, string contents, Encoding encoding);
}
```

## キーファイルを用いたリポジトリ

### FileRepositoryUsingKeyFile&lt;TEntity, TKey&gt; クラス

キーファイルを用いてエンティティファイルのパスを管理するリポジトリです。

* FileRepositoryBase&lt;TEntity, TKey&gt; クラスを継承します。
* ファイル名を決定する処理は IEntityFileNamingUsingKeyFile&lt;TEntity, TKey&gt; インターフェースを実装したオブジェクトに委譲します。
* ファイル入出力処理は IFileManager インターフェースを実装したオブジェクトに委譲します。

```c#
public class FileRepositoryUsingKeyFile<TEntity, TKey>
    : FileRepositoryBase<TEntity, TKey>
{
    public FileRepositoryUsingKeyFile(
        string dataDirectoryPath,
        IEntityFileNamingUsingKeyFile<TEntity, TKey> fileNaming,
        IFileManager fileManager,
        Encoding encoding
    )
        : base(dataDirectoryPath)
    {
        m_EntityFileNaming = fileNaming;
        m_FileManager = fileManager;
        m_Encoding = encoding;
    }

    // omission
}
```

### IEntityFileNamingUsingKeyFile&lt;TEntity, TKey&gt; インターフェース

キーファイルを用いてエンティティファイルのパスを管理するために必要な機能を提供するインターフェースです。

* 前述のサンプルの SampleEntityRepositoryUsingKeyFile クラスでは、CreateEntityFileNaming メソッドでこのインターフェースを実装するオブジェクトを生成しています。

```c#
/// <summary>
/// Provides the functionality required to manage entity file paths using key files.
/// </summary>
/// <typeparam name="TEntity">The entity type.</typeparam>
/// <typeparam name="TKey">The key type.</typeparam>
public interface IEntityFileNamingUsingKeyFile<TEntity, TKey>
{
    /// <summary>
    /// Gets a value indicating whether the specified file is a key file.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    /// <returns>Returns true if it is a key file, otherwise false.</returns>
    bool IsKeyFile(string filePath);

    /// <summary>
    /// Gets a value indicating whether the specified file is an entity file.
    /// </summary>
    /// <param name="filePath">The file path.</param>
    /// <returns>Returns true if it is an entity file, otherwise false.</returns>
    bool IsEntityFile(string filePath);

    /// <summary>
    /// Gets the key corresponding to the specified key file name.
    /// </summary>
    /// <param name="keyFilePath">The key file name.</param>
    /// <returns>The key.</returns>
    TKey GetKeyFromFileName(string keyFilePath);

    /// <summary>
    /// Gets the key file name corresponding to the specified key.
    /// </summary>
    /// <param name="key">The key.</param>
    /// <returns>The key file name.</returns>
    string GetKeyFileName(TKey key);

    /// <summary>
    /// Gets the file path where the specified entity will be saved.
    /// </summary>
    /// <param name="entity">The entity.</param>
    /// <returns>The file path.</returns>
    string GetEntitySaveFileName(TEntity entity);

    /// <summary>
    /// Gets the key for the specified entity.
    /// </summary>
    /// <param name="entity">The entity.</param>
    /// <returns>The key.</returns>
    TKey GetKey(TEntity entity);
}
```

## おわりに

[前回](https://qiita.com/mxProject/items/2de1ca32db89995179fa)紹介したデータベース用リポジトリと同じインターフェースを実装しています。トランザクション制御の違いに注意する必要はありますが、データベースとファイルシステムを併用する場合でも透過的な実装ができるようになります。

今回紹介したリポジトリは単純なものですが、ドキュメントデータベースを用いるリポジトリなどにも同じ考え方を適用することができると思います。