初めに
フロントエンド側のお話はコチラの記事をご覧ください. F#で数理最適化をやるにあたり, 何かしらを作ってみようということで, シフトスケジューリングシステムを作ってみました.
今回の記事では, バックエンド側として, C#のWebAPIフレームワークによるAPIの部分と, 簡単なコアモジュールなどについて解説します. F#ユーザのみならず, C#でWebAPIを立てる人にとって, お役に立てれば幸いです.
なお, .NETはバージョン10.0.109を使用しています.
今回の設計/実装においては, DBを一切合切省いています.
今回はドメインロジックと計算エンジンの結合に焦点を当てるため, 永続化層はあえてスコープ外としています
目次
プロジェクト全体の概略
今回のバックエンド側は, 3つのプロジェクトで成り立っています. 全てdotnetなので, シームレスな連携が可能です.
| proj name | lang | template | packages | description |
|---|---|---|---|---|
| CommonDomain | C# | classlib |
- | WebAPIのスキーマ定義の格納庫 |
| Web | C# | webapi |
swagger |
WebAPIの入口 |
| CalculatorCore | F# | classlib |
Flips |
計算機 |
ディレクトリ構造は以下の通りです.
ディレクトリ構造
.
├── CalculatorCore/
│ ├── CalculatorCore.fsproj
│ ├── Models/
│ │ ├── Converter.fs
│ │ └── Domain.fs
│ ├── Optimizer/
│ │ └── ShiftSolver.fs
│ └── OptimizerEntry.fs
├── CommonDomain/
│ ├── CommonDomain.csproj
│ └── Schemas/
│ ├── CalcRequest.cs
│ ├── CalcResponse.cs
│ ├── Person.cs
│ └── ShiftRequest.cs
├── Web/
│ ├── Properties/
│ │ └── launchSettings.json
│ ├── Program.cs
│ ├── Web.csproj
│ ├── Web.http
│ ├── appsettings.Development.json
│ └── appsettings.json
├── Makefile
├── README.md
└── dotnet-tools.json
また, プロジェクト間の参照関係は以下の通りです
シンプルな作りにしていますが, ほんの少しだけ解説をば.
Web --> CommonDomainの矢印と, CalculatorCore --> CommonDomainの矢印により, WebAPIと計算機の両方が同様のJSONスキーマを参照することになります. これにより, WebAPI側と計算機の両方が共通基盤としてのスキーマを使用することが可能になり, お互いの変更に対して堅牢になります.
以降は各プロジェクトの概要と簡単な説明をしていきます.
CommonDomainプロジェクト
概要
C#によるWebAPIのスキーマ定義に使用されるプロジェクトです. 先述しましたが, WebAPIのプロジェクトと計算機のプロジェクトの両方から参照されます. 両方から参照することで, 共通の基盤としての役割を果たします.
詳細
ディレクトリ構造は以下の通りです. シンプルにCommonDomain.Schemasを名前空間とし, その直下にAPIでやり取りするため, JSONスキーマを作成しています.
.
└── CommonDomain/
├── CommonDomain.csproj
└── Schemas/
├── CalcRequest.cs
├── CalcResponse.cs
├── Person.cs
└── ShiftRequest.cs
ファイル間の従属関係は以下の通りです.
※矢印の方向にファイルが従属, CalcResponseは独立
それぞれのファイルの全文を載せます. 適宜ご参照ください.
Person.csの全文
// CommonDomain/Schemas/Person.cs
using System.Text.Json.Serialization;
namespace CommonDomain.Schemas;
/// <summary>
/// 人情報
/// </summary>
/// <value></value>
public record Person
{
/// <summary>
/// ユーザID
/// </summary>
/// <value>int identifier</value>
[JsonPropertyName("idf")]
public required int Idf { get; init; }
/// <summary>
/// 従業員名
/// </summary>
/// <value></value>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// リーダーフラグ
/// </summary>
/// <value></value>
[JsonPropertyName("is_leader")]
public required bool IsLeader { get; init; }
}
ShiftRequest.csの全文
// CommonDomain/Schemas/ShiftRequest.cs
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace CommonDomain.Schemas;
/// <summary>
/// 希望休の情報
/// </summary>
/// <value></value>
public record ShiftRequest
{
/// <summary>
/// ユーザID
/// </summary>
/// <value></value>
[JsonPropertyName("person_idf")]
public required int PersonIdf { get; init; }
/// <summary>
/// 希望休の日付リスト
/// </summary>
/// <value></value>
[JsonPropertyName("holiday_requests")]
public required List<int> HolidayRequests { get; init; }
/// <summary>
/// 当該月に出勤出来る最大の日数
/// </summary>
/// <value></value>
[JsonPropertyName("max_shift")]
public required int MaxShift { get; init; }
}
CalcRequest.csの全文
// CommonDomain/Schemas/CalcRequest.cs
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace CommonDomain.Schemas;
/// <summary>
/// 計算リクエスト
/// </summary>
/// <value></value>
public record CalcRequest
{
/// <summary>
/// ユーザID vs リクエスト
/// </summary>
/// <value></value>
[JsonPropertyName("shift_requests")]
public required Dictionary<int, ShiftRequest> ShiftRequests { get; init; }
/// <summary>
/// ユーザID vs 人情報
/// </summary>
/// <value></value>
[JsonPropertyName("person_info")]
public required Dictionary<int, Person> PersonInfo { get; init; }
/// <summary>
/// 日付 vs 必要な人員数, 0なら定休日と解釈する
/// </summary>
/// <value></value>
[JsonPropertyName("resource_requests")]
public required Dictionary<int, int> ResourceRequests { get; init; }
}
CalcResponse.csの全文
// CommonDomain/Schemas/CalcResponse.cs
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace CommonDomain.Schemas;
/// <summary>
/// 計算レスポンス
/// </summary>
/// <value></value>
public record CalcResponse
{
/// <summary>
/// 計算が成功したか否か
/// </summary>
/// <value></value>
[JsonPropertyName("success")]
public required bool Success { get; init; }
/// <summary>
/// メッセージ
/// </summary>
/// <value></value>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>
/// 計算に掛かった時間
/// </summary>
/// <value></value>
[JsonPropertyName("calc_time")]
public required double CalcTime { get; init; }
/// <summary>
/// 計算結果.
/// </summary>
/// <value>ユーザID vs 出勤日リスト</value>
[JsonPropertyName("result_dict")]
public required Dictionary<int, List<int>> ResultDic { get; init; }
}
上記の4ファイルを前提にして, 2点ほど, 簡単なTipsをお話しします.
データモデルの表現方法
1点目, そもそもどのようにC#でデータモデルを表現するか?ですが, System.Text.Json.Serializationを使用すると具合が良いです.
例えば, Pythonでデータの送信を行う場合, データモデルのフィールドはスネークケース(my_name)で記載することが多いと思います. C#やF#ではパスカルケース(MyName)/キャメルケース(myName)を使用することが多いので, その辺をイイ感じに変換してほしいわけですね. そこで, System.Text.Json.Serializationです. 例えば, Personクラスを例にとると,
using System.Text.Json.Serialization;
namespace CommonDomain.Schemas;
/// <summary>
/// 人情報
/// </summary>
/// <value></value>
public record Person
{
/// <summary>
/// ユーザID
/// </summary>
/// <value>int identifier</value>
[JsonPropertyName("idf")]
public required int Idf { get; init; }
// 後略
}
となり, JsonPropertyNameを入れることで, WebAPIで受け取りデータのフィールドを, 自動で変換してくれます. 個人的には, このハンドリングがかなり便利であると感じています.
ドメイン分割
2点目, 個人的にですが, 出来るだけ"ドメイン"を分割した方が良いのかな?と思います. 今回の例では, Personクラスに対して従業員IDや従業員名等, 人事的な情報を格納し, 計算に必要なリクエスト周りは, ShiftRequestに格納しています. 上記2つのクラスをCalcRequestクラスに格納して紐づけることで, データモデルとして変更に強くなるように意識しています.
Webプロジェクト
概要
C#によるWebAPIのルーティングに使用される, WebAPIの入口のプロジェクトです. Pythonでいう所のFastAPI的な使い方だと思っています. 簡単にWebAPIの入口を作れます.
詳細
ディレクトリ構造は以下の通りです.
ディレクトリ構造
.
└── Web/
├── Properties/
│ └── launchSettings.json
├── Program.cs
├── Web.csproj
├── Web.http
├── appsettings.Development.json
└── appsettings.json
と言っても, 殆どのファイルは, テンプレートプロジェクトを立ち上げた瞬間に自動作成されます. 実際に手を入れるのは, Web/Program.csのみだと考えて差し支えないと思います.
もちろん, ルーティングやセキュリティ周りをちゃんと書きたいなら, 沢山手を入れるべきかと思います. このコードをそのままプロダクションコードにするのは危険だと思います.
まずは, Web/Program.csの全文です
Web/Program.csの全文
// Web/Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using CommonDomain.Schemas;
using CalculatorCore;
using System.IO;
using System;
namespace Web;
public class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// SwaggerGen を追加
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
// Swagger UI を有効化 (/swagger でアクセス可能)
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// 出力先ディレクトリを作成しておく
CreateOutDir(args);
MapEndpoints(app);
app.Run();
}
private static void CreateOutDir(string[] args)
{
string webDir = Directory.GetCurrentDirectory();
DirectoryInfo? info = Directory.GetParent(webDir);
if (info == null)
{
throw new Exception("No dir!");
}
else
{
string root = info.FullName;
foreach (string arg in args)
{
if (arg == "--root")
{
int rootKeyIndex = Array.IndexOf(args, "--root");
root = args[rootKeyIndex+1];
break;
}
else
{
// 処理なし
}
}
string outdirPath = $"{root}/Web/Data/Out";
Directory.CreateDirectory(outdirPath);
}
}
private static void MapEndpoints(WebApplication app)
{
// ヘルスチェック用
app.MapGet("/", () => "Shift Scheduler API is running!");
app.MapPost("/api/shift-solver/v1", (CalcRequest req) =>
{
return OptimizerEntry.calculateShift(req);
}).WithName("Solver");
}
}
重要な部分は実はこの部分だけです
// <前略>
using CommonDomain.Schemas;
using CalculatorCore;
// <中略>
public class Program
{
public static void Main(string[] args)
{
// <中略>
MapEndpoints(app);
app.Run();
}
private static void MapEndpoints(WebApplication app)
{
// ヘルスチェック用
app.MapGet("/", () => "Shift Scheduler API is running!");
app.MapPost("/api/shift-solver/v1", (CalcRequest req) =>
{
return OptimizerEntry.calculateShift(req);
}).WithName("Solver");
}
}
MapEndpointsという関数に, WebAPIのエンドポイントと実行するメソッド呼び出しが実装されています. CalculatorCore.OptimizerEntry.calculateShiftが, 実際に呼び出されている計算機側の関数ですね. この一行の呼び出しだけで計算が成立するように, CommonDomain.Schemas直下を丁寧に設計したとも言えます.
ついでに, Swashbuckle.AspNetCoreを入れることで, ドキュメントやテスト用UIを自動生成しています. 次のコマンド一発で入れられるので, 非常に便利ですね.
# プロジェクトのフォルダに移動
cd Web/
# nugetからパッケージ追加
dotnet add package Swashbuckle.AspNetCore
実装部分は以下の部分です. たった数行でドキュメントやテスト用UIを自動生成しています.
// 前略
public class Program
{
public static void Main(string[] args)
{
// 中略
// SwaggerGen を追加
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
// Swagger UI を有効化 (/swagger でアクセス可能)
app.UseSwagger();
app.UseSwaggerUI();
}
// 中略
}
// 後略
}
swaggerの使い心地については, Appendix-swaggerの使い心地を参照下さい.
CalculatorCoreプロジェクト
概要
F#による計算機コア部分に使用されるプロジェクトです. 今回は構成の話に終始します.※定式化と実装の詳細に関しては, それだけで1本記事が書けてしまいます
詳細
計算機エントリポイント
まずは計算機のエントリポイントである, CalculatorCore/OptimizerEntry.fsの実装をご覧ください.
// CalculatorCore/OptimizerEntry.fs
namespace CalculatorCore
open CalculatorCore.Models.Converter
open CalculatorCore.Optimizer.ShiftSolver
open CommonDomain.Schemas
/// シフト計算用のエントリポイント
module OptimizerEntry =
/// C#から呼び出すためのシフト計算関数
let calculateShift (req: CalcRequest) : CalcResponse =
req |> convertCsToFs |> solveShift |> convertFsToCs
予想以上に短いのではないでしょうか. 軽く解説します.
calculateShift関数は, C#からCalcRequestインスタンスを受け取り, C#のCalcResponseインスタンスを返します. 後述しますが, CalculatorCoreプロジェクトの内部用のモデルスキーマを通して計算を行っています.
今回はコンバータ関数にて, C#のモデルスキーマとF#のモデルスキーマを変換する処理を書いています. F#はデータパイプラインを綺麗に記述できるので, 個人的に気に入っています.
C#から入って来た方々向けの注意ですが, C#におけるライブラリのインポートはusingキーワードですが, F#においてはopenです.
独自ドメインの追加
Webにて, 計算リクエスト(CalcRequest)を受け取ります. 計算リクエストは, CommonDomainプロジェクトで規定されたC#の型を参照します. しかし, CalculatorCoreプロジェクトはF#のプロジェクトです. C#のオブジェクトをF#で扱うのは, 何となく扱いづらい気がしました. そこで, F#で計算オブジェクトを扱いやすくするため, 内部用のモデルスキーマと, コンバータを用意しました.
.
├── CalculatorCore/
│ ├── CalculatorCore.fsproj
│ ├── Models/
│ │ ├── Converter.fs # コンバータ
│ │ └── Domain.fs # 内部用モデルスキーマ
│ ├── Optimizer/
│ │ └── ShiftSolver.fs # 計算機
│ └── OptimizerEntry.fs # 計算機のエントリポイント
<後略>
実装は興味があったらどうぞ. 内部用モデルスキーマ, コンバータです.
内部用モデルスキーマ
// CalculationCore/Models/Domain.fs
namespace CalculatorCore.Models
module Domain =
/// シフトの問題定義(内部用)
type ShiftProblem =
{
/// スケジュール対象の日付リスト
DayList: int list
/// ユーザIdのリスト
UserList: int list
/// リーダーのインデックス
LeaderList: int list
/// 各日付で必要な人数
RequiredPeople: int list
/// 各ユーザが月に出勤できる最大日数
MaxShiftPerUser: int list
/// (ユーザ×日) → 出勤可能フラグ
AvailableShifts: int[,]
}
/// 計算が成功した場合のスケジュール
type Schedule =
{
/// シフト配列(trueで出勤)
Assignment: bool[,]
}
/// 計算が成功した場合の結果
type SuccessResult =
{
/// 計算結果復元用に元の問題を入れておく
Problem: ShiftProblem
/// スケジュール
Schedule: Schedule
/// 目的関数の値
ObjectiveValue: double
/// 計算時間[ms]
SolveTimeMs: double
}
/// 計算が失敗した場合の結果
type FaultResult =
{
/// 失敗した場合のメッセージ
Message: string
/// 計算時間[ms]
SolveTimeMs: double
}
/// 計算結果
type ShiftResult =
/// 解が見つかった
| Solved of SuccessResult
/// 解無し
| Infeasible of FaultResult
/// タイムアウト等その他の理由
| Unknown of FaultResult
モデルスキーマのコンバータ
// CalculationCore/Models/Converter.fs
namespace CalculatorCore.Models
open CalculatorCore.Models.Domain
open CommonDomain.Schemas
open System.Collections.Generic
/// C#<=>F#のデータコンバータモジュール
module Converter =
/// C#のデータをF#のデータにコンバートする
let convertCsToFs (req: CalcRequest) : ShiftProblem =
// スケジュール対象の日付リスト
let dayList: int list = req.ResourceRequests.Keys |> Seq.sort |> List.ofSeq
// ユーザIdのリスト
let userList: int list = req.PersonInfo.Keys |> Seq.sort |> List.ofSeq
// リーダーIdのリスト
let leaderList: int list =
userList
|> List.mapi (fun idx userId ->
if req.PersonInfo.[userId].IsLeader then Some idx else None
)
|> List.choose id
// 各日付で必要な人員
let requiredPeople: int list =
dayList |> List.map (fun key -> req.ResourceRequests.[key])
// 各ユーザで働ける最大の日数
let maxShiftPerUser: int list =
userList |> List.map (fun key -> req.ShiftRequests.[key].MaxShift)
// 各ユーザの希望休から算出した, 出勤可否の二次元配列
let availableShifts =
Array2D.init userList.Length dayList.Length (fun u d ->
let userId = userList.[u]
let dayId = dayList.[d]
let shiftReqs = req.ShiftRequests
let holidayReq = shiftReqs.[userId].HolidayRequests
// もし希望休に日程が含まれていたら,
// それは使えない日と言うことでゼロを入れる
if holidayReq.Contains(dayId) then 0 else 1)
{ DayList = dayList
UserList = userList
LeaderList = leaderList
RequiredPeople = requiredPeople
MaxShiftPerUser = maxShiftPerUser
AvailableShifts = availableShifts }
/// 成功バージョン
let private convertSuccess (successRes: SuccessResult) : CalcResponse =
// 割り当てと問題を取得しておく
let problem = successRes.Problem
let assignment = successRes.Schedule.Assignment
// C#用にディクショナリを作成する
let resultDic = new Dictionary<int, List<int>>()
let users: int = problem.UserList.Length
let days: int = problem.DayList.Length
for u in 0 .. users - 1 do
for d in 0 .. days - 1 do
if assignment.[u, d] then
let userId = problem.UserList.[u]
let dayId = problem.DayList.[d]
if not (resultDic.ContainsKey dayId) then
resultDic.[dayId] <- List<int>()
resultDic.[dayId].Add(userId)
CalcResponse(
Success = true,
Message = "Success!",
CalcTime = successRes.SolveTimeMs,
ResultDic = resultDic
)
/// 失敗バージョン
let private convertFault (faultRes: FaultResult) : CalcResponse =
let resultDic = new Dictionary<int, List<int>>()
CalcResponse(
Success = false,
Message = faultRes.Message,
CalcTime = faultRes.SolveTimeMs,
ResultDic = resultDic
)
/// F#のデータをC#のデータにコンバートする
let convertFsToCs (result: ShiftResult) : CalcResponse =
match result with
| Solved successRes -> convertSuccess successRes
| Infeasible faultRes -> convertFault faultRes
| Unknown faultRes -> convertFault faultRes
最後に
最後までお読みいただきありがとうございました. 次はシフトスケジューリングの定式化と, F#による実装の記事を書くと思います.

