昨天從紙卡上抄下三個名詞:書、會員、一次借還。今天它們第一次變成程式碼。
今天要做兩樣東西:一個種子資料產生器,和第一版的櫃檯程式。順序是刻意的,先做資料。
先做資料的原因很現實:這家店是虛構的,沒有真的卡片盒可以翻。接下來二十七天每一次量測都要有資料,而且必須是同一組資料——假如第十二天和第二十天各量到一個數字,底下不是同一家店,兩個數字擺在一起就沒有意義。
三個專案:產生器本體、它的測試、櫃檯程式。
mkdir highflyer-ironman && cd highflyer-ironman
dotnet new sln -n Ironman
dotnet new classlib -n Ironman.SeedData -o src/Ironman.SeedData
dotnet new xunit -n Ironman.SeedData.Tests -o tests/Ironman.SeedData.Tests
dotnet new console -n Ironman.Desk -o src/Ironman.Desk
dotnet sln add src/Ironman.SeedData tests/Ironman.SeedData.Tests src/Ironman.Desk
dotnet add tests/Ironman.SeedData.Tests reference src/Ironman.SeedData
dotnet add src/Ironman.Desk reference src/Ironman.SeedData
共用設定放在方案根目錄的Directory.Build.props,之後每加一個專案都不用再抄一次:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
TreatWarningsAsErrors是刻意的:三十天會累積不少程式碼,警告放著不管就會變成背景雜訊,之後真正要緊的那一個會被蓋掉。
再加一個global.json把SDK限制在 .NET 10,免得讀者的機器上裝了別的主要版本,跑出來的東西和文章對不上:
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
rollForward設成latestFeature,10.0.3xx這類同主版本的更新也能跑,不用每次升級都改檔案。反過來說它也不是鎖定同一版:筆者機器上實際選到的是10.0.301。要完全一致得改成disable,這裡不需要做到那個程度。
src/Ironman.SeedData/Models.cs。一行一個名詞,註解寫著它對應紙卡上的哪個位置:
namespace Ironman.SeedData;
/// <summary>書目資料。對應紙卡:借閱卡上緣的書名、作者、ISBN。</summary>
public sealed record Book(string Isbn, string Title, string Author);
/// <summary>會員。對應紙卡:會員卡上的會員編號、姓名、電話。</summary>
public sealed record Member(string MemberId, string Name, string Phone);
/// <summary>一次借出與歸還。對應紙卡:借閱卡上的每一行。</summary>
public sealed record Loan(string MemberId, string Isbn, DateOnly LoanDate, DateOnly? ReturnDate)
{
public bool IsReturned => ReturnDate is not null;
}
/// <summary>一組種子資料。三個集合之間的參照(MemberId、Isbn)保證存在。</summary>
public sealed record SeedDataSet(
Scale Scale,
int Seed,
IReadOnlyList<Book> Books,
IReadOnlyList<Member> Members,
IReadOnlyList<Loan> Loans);
有三個決定要交代。
用record不是為了時髦。這三個東西目前只有資料、沒有行為;而且record比較相等是逐欄位比,等一下那條「同一個seed產生完全相同的資料」的測試才能直接拿兩組資料比——一般的class預設比的是「是不是同一個物件」,兩筆欄位一模一樣的Book會被判定成不同。
ReturnDate是DateOnly?,不是一個IsReturned布林欄位。紙卡上沒有打勾欄,有的是「歸還日期」那一格:填了就是還了,空著就是還在外面。null就是那一格空白。IsReturned只是讀那一格的方便寫法,不是另一份資料——兩份資料就會有對不起來的一天。
Loan指向Isbn,不是指向某一本實體書。現在每種書只有一本,這樣夠用。同一本書買了兩本會出什麼事,第六天會真的發生,那時再改。
public enum Scale { S, M, L }
public readonly record struct ScaleProfile(int Books, int Members, int Loans)
{
public static ScaleProfile For(Scale scale) => scale switch
{
Scale.S => new(Books: 100, Members: 40, Loans: 500),
Scale.M => new(Books: 2_000, Members: 600, Loans: 50_000),
Scale.L => new(Books: 20_000, Members: 5_000, Loans: 1_000_000),
_ => throw new ArgumentOutOfRangeException(nameof(scale), scale, "未定義的規模"),
};
}
S是開店第一年、M是第三年、L是第十年。這三組數字從今天釘死,往後不再改——改了,前面所有量測就得重跑。
書名和人名由一個小詞庫組合出來,不對應任何真實的人或書。ISBN比較講究一點,因為它有規則可循:
/// <summary>ISBN-13 檢查碼:奇數位權重 1、偶數位權重 3,總和補到 10 的倍數。</summary>
public static int CheckDigit(ReadOnlySpan<int> first12)
{
var sum = 0;
for (var i = 0; i < 12; i++)
{
sum += first12[i] * (i % 2 == 0 ? 1 : 3);
}
return (10 - sum % 10) % 10;
}
為什麼不隨便產十三位數字就好?因為之後幾篇會拿ISBN當鍵去查、去比對、去當關聯的依據。假資料如果連自己的規則都不守,出問題的時候分不清是程式錯還是資料本來就爛。
這只做到「978開頭、檢查碼算得過」,不代表這組號碼沒有被真的配發給某本書——那要查ISBN的官方配發紀錄才知道,這裡不需要。
關鍵只有一行:new Random(seed)。
public const int DefaultSeed = 20260905;
public static SeedDataSet Generate(Scale scale, int seed = DefaultSeed)
{
var profile = ScaleProfile.For(scale);
var random = new Random(seed);
var books = GenerateBooks(random, profile.Books);
var members = GenerateMembers(random, profile.Members);
var (periodStart, periodEnd) = PeriodFor(scale);
var loans = GenerateLoans(random, books, members, profile.Loans, periodStart, periodEnd);
return new SeedDataSet(scale, seed, books, members, loans);
}
在同一個 .NET 主要版本、同一份詞庫、同一個取數順序底下,同一個種子會得到同一組資料。官方保證的就是這個範圍——Random的演算法不保證跨主要版本不變,所以global.json那一段不只是潔癖。沒指定種子的new Random()則沒有這個性質:拿不到一個可以重現的起點。
所以整個產生器只有這一個亂數實例,三個集合按固定順序取數,也不用平行處理——順序一亂,結果就變了。讀者用專案指定的 .NET 10 跑Generate(Scale.S),拿到的會是同一位會員借走同一本書、同一天借的。文章裡的數字可以自己對。
不能破的規則先列十條,全部寫成測試:
同一個seed產生完全相同的資料
不同seed產生不同的資料
每個規模的數量符合定義
ISBN不重複且檢查碼有效
會員編號不重複
每筆借閱都指向存在的書與會員
歸還日不早於借出日
同一本書的借閱期間不重疊
借閱日期都落在該規模的期間內
有一部分借閱尚未歸還
第一條是這個產生器存在的理由,寫出來只有三行:
[Fact]
public void 同一個seed產生完全相同的資料()
{
var a = SeedDataGenerator.Generate(Scale.S);
var b = SeedDataGenerator.Generate(Scale.S);
Assert.Equal(a.Books, b.Books);
Assert.Equal(a.Members, b.Members);
Assert.Equal(a.Loans, b.Loans);
}
Assert.Equal直接拿兩組資料比,靠的就是前面選record那個決定。
最後一條看起來最無聊,它只要求至少有一筆還沒還:
[Fact]
public void 有一部分借閱尚未歸還()
{
var data = SeedDataGenerator.Generate(Scale.S);
Assert.Contains(data.Loans, l => !l.IsReturned);
}
這十條規則跑出十六個測試案例(有一條用[Theory]帶了三個規模,另外還有ISBN檢查碼自己的測試)。就這一條紅了。
[xUnit.net 00:00:00.07] SeedDataGeneratorTests.有一部分借閱尚未歸還 [FAIL]
Error Message:
Assert.Contains() Failure: Filter not matched in collection
Failed! - Failed: 1, Passed: 15, Skipped: 0, Total: 16
五百筆借閱裡,未歸還是零。
筆者把資料印出來翻才看懂:借閱全擠在前面幾個月,最後一筆借出停在九月二十一日;十月、十一月、十二月,整家店架上乾乾淨淨,沒有一本書在客人手上。
一家還在營業的租書店不會長這樣。跨年那天,櫃檯的卡片盒裡一定有一疊卡還插著——那就是昨天說的、老闆晚上要數的那一疊。連續三個月未歸還都是零,現實裡只有一種解釋:這家店九月就收攤了。
產生器沒有寫錯任何一行。編譯過了,其他十五個案例也都過了。它只是造出一家不可能存在的店。
問題出在一行想當然耳的設定。筆者原本寫「一本書還回來之後,等0到20天再被借走」——書還回來、在架上躺幾天、又被借走,聽起來很合理。但五百筆借閱配一百本書,平均每本五筆;等待平均十天、借閱平均十五天多,一筆佔二十幾天,照平均五筆估算,排完連九個月都不到。
等待天數不能寫死,要跟著期間長度走:
// 兩次借出之間的等待天數上限,依「每本書要在期間內排進幾筆」回推,
// 讓借閱平均分布在整個期間,而不是擠在期間開頭。
var periodDays = periodEnd.DayNumber - periodStart.DayNumber;
var loansPerBook = (double)count / books.Count;
var slotDays = periodDays * 0.85 / loansPerBook; // 每筆借閱平均佔用的天數(留 15% 餘裕)
var maxWaitDays = Math.Max(1, (int)(2 * (slotDays - AverageLoanDays)));
兩個數字要分開講。乘2是因為random.Next(0, max + 1)的平均值大約是上限的一半,要讓平均等到slotDays - AverageLoanDays那麼久,上限就得放到兩倍。0.85不是推導出來的,是試出來的餘裕,留15%給期間前後的邊界,免得最後一筆衝出營運期間。
S規模因此變成0到93天。書會在架上多躺一陣子,年底才有書還在外面,那條測試轉綠——十六個案例全過。這只代表目前列出的規則沒有被破,不代表這家店已經像真的;至少它不再是年底一筆未歸還都沒有的店。
這一條測的不是「這段程式有沒有算錯」,是「造出來的資料像不像真實世界會發生的事」。放著不管的代價不是當機:第三十天之前還有二十七天的量測——查詢要多久、記憶體吃多少、逾期罰金怎麼算——全部都會跑在一家沒有人借書的空店上。未歸還永遠是零,「查會員未歸還」永遠回空的,量出來的數字看起來很正常,而且不會有任何錯誤訊息。
這個產生器交給AI寫,它寫得比筆者快,很可能一次就把程式寫到能跑、測試也能過。它不會問的是:這家店年底有書在外面嗎。
有了資料,才輪到程式。老闆買了一台電腦放櫃檯,早晚班兩位店員輪流用,第一版是一支命令列程式。
借出規則住在RentalDesk裡:
public Loan Lend(string memberId, string isbn, DateOnly today)
{
var member = FindMember(memberId)
?? throw new RentalException($"找不到會員 {memberId},先確認會員卡上的編號。");
var book = FindBook(isbn)
?? throw new RentalException($"找不到 ISBN {isbn} 的書,這本書沒有建檔。");
var outstanding = OutstandingLoanOf(isbn);
if (outstanding is not null)
{
var holder = FindMember(outstanding.MemberId)?.Name ?? outstanding.MemberId;
throw new RentalException($"《{book.Title}》已經在 {outstanding.LoanDate:yyyy-MM-dd} 借給 {holder},還沒回來。");
}
var loan = new Loan(member.MemberId, book.Isbn, today, ReturnDate: null);
_loans.Add(loan);
return loan;
}
那三個throw就是把判斷搬到程式裡。以前店員要翻卡片盒才知道這本書在不在外面,翻到了才擋得住;現在程式先替兩個班別做同一個判斷,而且不會漏。
歸還那一段換了個做法:
// Loan 是不可變的 record,所以「歸還」是用一筆填好歸還日的新紀錄,換掉舊的那一筆。
var returned = outstanding with { ReturnDate = today };
var index = _loans.IndexOf(outstanding);
_loans[index] = returned;
紙卡上店員是在同一張卡上補寫歸還日期,程式這邊選了另一種做法——這個選擇到第二十四天談交易的時候會再回來。
啟動起來長這樣(2026-09-16跑的,第一行的日期取自系統時鐘,其餘四個數字來自種子資料,讀者跑起來會一樣):
=== 租書店櫃檯 v1 ===
今天 2026-09-16|書 100 本|會員 40 位|借閱紀錄 500 筆|未歸還 37 筆
1) 借出 2) 歸還 3) 查會員未歸還 4) 查書可不可借 5) 搜尋書名 0) 離開
> 會員編號:ISBN:✗ 《兩個人的偵探》已經在 2025-12-28 借給 李子涵,還沒回來。
選單的3和4是「查會員未歸還」和「查書可不可借」。昨天那盒卡片只能擇一,這裡兩種都有——因為資料在記憶體裡,同一份資料可以從兩個方向找。現在兩邊都還是從頭找到尾,昨天講的索引還沒進場。
代價寫在Program.cs第一行的註解裡:
// 第一版櫃檯程式:載入 S 規模種子資料到記憶體,然後進入選單迴圈。
// 程式關掉,這段時間借出、歸還的紀錄就全部消失——這是故意留下的問題。
關機就忘了。
第四天把借還規則補成十條測試,順便回答一個問題:FindBook現在是_books.FirstOrDefault(b => b.Isbn == isbn),一百本書跑起來很快,那兩千本呢。
今天的程式碼會放在day03這個tag,取得出來、跑得起來:
https://github.com/EugeneSu0515/highflyer-ironman/tree/day03
git clone --branch day03 https://github.com/EugeneSu0515/highflyer-ironman.git
cd highflyer-ironman
dotnet test
dotnet run --project src/Ironman.Desk
這個系列是筆者正在寫的一套教學內容的濃縮版;完整版含每一次量測的原始輸出與更長的決策紀錄,三十天結束後會繼續往下走。
程式碼:https://github.com/EugeneSu0515/highflyer-ironman