Giải pháp đầy đủ: kiểu phạm vi giàu ngữ nghĩa trong tầng miền, ánh xạ trọn vẹn sang PostgreSQL ở tầng dữ liệu, không phải đánh đổi ở bất kỳ đầu nào.
Mục lục
Cạm bẫy hai cột
Hầu như lập trình viên nào cũng từng viết một lớp có hai thuộc tính ngày tháng như sau:
public class MemberSubscription
{
public int Id { get; set; }
public int MemberId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
Giả sử bạn cần trả lời một câu hỏi tưởng như đơn giản trong hệ thống đặt chỗ: “Gói đăng ký này còn hiệu lực không, và nó có xung đột với gói mới hay không?”. Khi chỉ có hai trường dữ liệu rời rạc, mã thường trở nên như sau:
public static bool IsActive(MemberSubscription sub, DateTime at)
=> sub.StartDate <= at &&
(sub.EndDate == default || sub.EndDate > at);
public static bool ConflictsWith(MemberSubscription a, MemberSubscription b)
{
if (a.StartDate >= b.StartDate && a.StartDate < b.EndDate)
return true;
if (b.StartDate >= a.StartDate && b.StartDate < a.EndDate)
return true;
if (a.StartDate <= b.StartDate && a.EndDate >= b.EndDate)
return true;
return false;
}
Thoạt nhìn, đoạn mã có vẻ hợp lý. Nhưng khi đặt thêm câu hỏi, bạn sẽ thấy thiết kế này đòi hỏi quá nhiều kiến thức ngầm. EndDate có được phép đứng trước StartDate không? Hệ thống kiểu không nói gì. Ngày kết thúc bằng null có nghĩa là không bao giờ hết hạn chứ? Mô hình cũng không thể hiện điều đó. Một gói kết thúc trong ngày hôm nay còn hiệu lực lúc 23:59 hay không? Hỏi ba lập trình viên, rất có thể bạn sẽ nhận được ba câu trả lời.
Giá trị đặc biệt EndDate == default cho gói không có ngày kết thúc bị rò rỉ vào mọi nơi gọi. Câu hỏi về khoảng nửa kín hay kín, tức dùng < hay <= cho ngày kết thúc, lại được mỗi người xử lý theo một cách. Các trường hợp biên âm thầm tích tụ.
Lớp này không có bất biến, không đóng gói và cũng không có một biểu diễn duy nhất cho khái niệm mà nó cần mô hình hóa: một khoảng thời gian. Quy tắc giữa hai ngày nằm rải rác trong bộ kiểm tra, phương thức dịch vụ, mệnh đề SQL WHERE và kinh nghiệm truyền miệng của người viết mã ban đầu.
Đây chính là cạm bẫy hai cột. Nó không chỉ xuất hiện trong gói đăng ký mà còn ở lịch ca làm, hệ thống đặt chỗ, bậc giá, thời hạn hiệu lực, lịch biểu phí, giai đoạn chiến dịch và mọi nơi mà một khoảng giá trị là khái niệm có ý nghĩa trong miền nghiệp vụ.
Nếu có thể mô hình hóa theo cách khác
Hãy xem cùng một logic khi khoảng thời gian đăng ký trở thành một DateRange thực sự:
public sealed class MemberSubscription
{
public int MemberId { get; }
public DateRange Period { get; }
public MemberSubscription(int memberId, DateRange period)
{
if (period is DateRange.EmptyRange)
throw new DomainException(
"Subscription period must cover at least one day.");
MemberId = memberId;
Period = period;
}
public bool IsActiveOn(DateOnly date)
=> Period.Contains(date);
public bool ConflictsWith(MemberSubscription other)
=> Period.Overlaps(other.Period);
}
var lifetime = new MemberSubscription(
42,
DateRange.CreateUnboundedEnd(
DateOnly.FromDateTime(DateTime.Today)));
var annual = new MemberSubscription(
7,
DateRange.CreateFinite(
new DateOnly(2025, 1, 1),
new DateOnly(2025, 12, 31)));
lifetime.IsActiveOn(new DateOnly(2099, 1, 1)); // true
annual.ConflictsWith(lifetime); // true
Không còn giá trị đặc biệt cho “không bao giờ hết hạn”. Không cần tự viết, kiểm tra hay duy trì logic phát hiện giao nhau. Không còn giả định ẩn về việc ngày kết thúc là kín hay hở; kiểu dữ liệu đã mã hóa quy tắc đó. Bất biến “gói đăng ký phải kéo dài ít nhất một ngày” được bảo vệ ngay trong hàm tạo, thay vì rải rác ở các nơi gọi.
PostgreSQL đã hiểu bài toán này từ lâu
PostgreSQL hỗ trợ kiểu phạm vi từ phiên bản 9.2. Các kiểu int4range, int8range, numrange, daterange, tsrange và tstzrange cho phép lưu một khoảng trong một cột duy nhất, đồng thời cung cấp cả đại số toán tử cho việc chứa, giao nhau, kề nhau, hợp, giao và hiệu.
Với cột daterange, bạn có thể khai báo ràng buộc loại trừ để khiến các lượt đặt chỗ giao nhau trở nên bất khả thi ngay tại tầng cơ sở dữ liệu:
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (resource_id WITH =, period WITH &&);
Điều này không chỉ ngăn dữ liệu sai mà còn ngăn nó một cách nguyên tử, bất kể có bao nhiêu thực thể ứng dụng đang chạy đồng thời. Không cần tự xử lý điều kiện tranh chấp, vòng lặp thử lại hay khóa Redis. Cơ sở dữ liệu sẽ thực thi quy tắc.
Từ PostgreSQL 14, kiểu đa phạm vi còn cho phép một cột chứa một tập các khoảng không giao nhau, đã được chuẩn hóa và có thể truy vấn bằng cùng hệ toán tử.
Câu hỏi dành cho lập trình viên .NET là: làm sao đưa sự giàu ngữ nghĩa này vào mô hình miền?
NpgsqlRange<T> thực sự là gì?
Npgsql, trình điều khiển .NET cho PostgreSQL, biểu diễn kiểu phạm vi thông qua NpgsqlRange<T>. Đây là một readonly struct mô phỏng biểu diễn trên đường truyền của PostgreSQL: hai cận có thể rỗng và một giá trị cờ mô tả tính bao gồm cũng như tính vô hạn.
Hình dạng của phạm vi không nằm trong kiểu tĩnh mà nằm trong các bit cờ. Người dùng phải kiểm tra cờ trước khi truy cập giá trị cận. Đây là thiết kế phù hợp cho kiểu dữ liệu trên đường truyền, vì NpgsqlRange<T> được tạo ra để chuyển dữ liệu giữa .NET và PostgreSQL, đồng thời tham gia dịch LINQ thành SQL.
Tuy nhiên, nó không phù hợp làm nguyên thủy cho mô hình miền. Các phương thức như Contains, Overlaps, IsAdjacentTo, Union, Intersect và Except trong gói EF Core của Npgsql thực chất chỉ là điểm đánh dấu cho trình dịch:
public static bool Contains<T>(this NpgsqlRange<T> range, T value)
=> throw new InvalidOperationException();
public static bool Overlaps<T>(
this NpgsqlRange<T> a,
NpgsqlRange<T> b)
=> throw new InvalidOperationException();
Gọi chúng bên ngoài truy vấn LINQ thực thi trên DbSet sẽ gây ra InvalidOperationException. Đây không phải lỗi thiết kế; đó là lựa chọn đúng cho một kiểu phục vụ giao thức và dịch SQL. Nhưng hệ quả là .NET thiếu một kiểu phạm vi chạy trong bộ nhớ.
Bạn không thể dùng NpgsqlRange<T> để viết phương thức miền thuần túy, không thể kiểm thử logic phạm vi mà không có cơ sở dữ liệu và cũng phải đưa Npgsql vào các tầng khác của ứng dụng. Phụ thuộc không thuần túy nên nằm ở biên, không nên đi vào tầng miền.
Giới thiệu CodoMetis.ValueRanges
CodoMetis.ValueRanges được tạo ra để lấp đầy khoảng trống đó:
dotnet add package CodoMetis.ValueRanges
Thư viện cung cấp các kiểu phạm vi cụ thể, an toàn kiểu, tương ứng với sáu miền giá trị mà PostgreSQL hỗ trợ. Mọi phép toán phạm vi và đa phạm vi đều được thực hiện trong bộ nhớ. Thư viện yêu cầu .NET 10 trở lên và không phụ thuộc vào Npgsql, EF Core hay trình điều khiển cơ sở dữ liệu.
Int32Rangetương ứng vớiint4rangeInt64Rangetương ứng vớiint8rangeDecimalRangetương ứng vớinumrangeDateRangetương ứng vớidaterangeDateTimeRangetương ứng vớitsrangeDateTimeOffsetRangetương ứng vớitstzrange
Tập kiểu được hỗ trợ cố ý giữ cố định. Việc mở rộng thành kiểu tổng quát tùy ý nghe có vẻ hấp dẫn nhưng gây nhiều vấn đề. float và double có NaN, vô cực và không có thứ tự toàn phần đáng tin cậy cho phép tính khoảng. Guid có thể sắp xếp nhưng thứ tự của nó không mang ngữ nghĩa miền phù hợp. Sáu kiểu trên là tập hợp PostgreSQL tự hỗ trợ và đã kiểm chứng cho ngữ nghĩa khoảng.
Hệ thống kiểu: vì sao không dùng readonly record struct?
readonly record struct rất phù hợp cho các đối tượng giá trị đơn giản. Nhưng struct không thể trừu tượng, không thể có kiểu dẫn xuất được niêm phong và không tham gia đa hình. C# cũng không thể tạo một liên hợp phân biệt từ các struct.
Một kiểu phạm vi có năm hình dạng cơ bản:
- Finite: có cả
StartvàEnd. - UnboundedStart: chỉ có
End. - UnboundedEnd: chỉ có
Start. - Infinity: không có cận nào, bao phủ toàn bộ miền.
- Empty: không chứa giá trị nào.
Nếu dồn tất cả vào một struct, bạn sẽ kết thúc với các cận nullable và nhiều cờ boolean. Đó chính là vấn đề của NpgsqlRange<T>: người gọi phải kiểm tra cờ trước khi đọc thuộc tính.
CodoMetis.ValueRanges chọn hướng ngược lại: trạng thái không hợp lệ không thể biểu diễn ngay từ cấu trúc kiểu. Hình dạng phạm vi được mã hóa trong kiểu tĩnh:
public abstract record DateRange
{
private DateRange() { }
public sealed record EmptyRange : DateRange;
public sealed record Finite : DateRange
{
// Start, End, and boundary information
}
public sealed record UnboundedStart : DateRange;
public sealed record UnboundedEnd : DateRange;
public sealed record Infinity : DateRange;
}
Hàm tạo riêng khiến hệ phân cấp bị đóng đối với mã bên ngoài. Mỗi kiểu phạm vi là một abstract record với năm biến thể sealed. Cú pháp record cung cấp so sánh cấu trúc và hỗ trợ biểu thức with, rất phù hợp cho đối tượng miền bất biến.
Đổi lại, mỗi phạm vi cần một lần cấp phát trên heap. Với các đối tượng miền mô hình hóa khoảng trong bối cảnh nghiệp vụ, chi phí này thường chấp nhận được. Đường chạy cần hiệu năng cao vẫn nằm ở PostgreSQL, không phải tầng miền.
Hệ phân cấp giao diện
IRange<T>: giao diện cơ sở cho mọi biến thể.IFiniteRange<T>:Start,End,StartInclusive,EndInclusive.IUnboundedStartRange<T>:End,EndInclusive.IUnboundedEndRange<T>:Start,StartInclusive.IEmptyRange<T>: chỉ là giao diện đánh dấu.IInfinityRange<T>: biểu diễn toàn bộ miền.IRangeFactory<TRange, T>: các hàm tạo tĩnh cùngNextValueAftervàPreviousValueBefore.
IRangeFactory<TRange, T> sử dụng thành viên giao diện trừu tượng tĩnh của C# 11 để yêu cầu các hàm tạo ở cấp kiểu mà không cần điều phối thời gian chạy. Nhờ đó, các phép toán tập hợp có thể trả về đúng kiểu cụ thể mà không cần phản chiếu.
Tạo phạm vi
Int32Range closed = Int32Range.CreateFinite(1, 10); // [1, 10]
DecimalRange halfOpen = DecimalRange.CreateFinite(1m, 5m); // [1, 5)
DateRange upTo = DateRange.CreateUnboundedStart(
new DateOnly(2025, 12, 31)); // (-∞, 2025-12-31]
Int32Range fromFive = Int32Range.CreateUnboundedEnd(5); // [5, +∞)
Int32Range everything = Int32Range.Infinite; // (-∞, +∞)
Int32Range empty = Int32Range.Empty; // ∅
CreateFinite không ném ngoại lệ. Nếu cận bị đảo hoặc suy biến, hàm trả về Empty. Với kiểu rời rạc, cận hở được chuẩn hóa thành khoảng kín tương đương: (1, 5) trên số nguyên trở thành [2, 4], còn (1, 2) trở thành Empty. Vì vậy, hai phạm vi biểu diễn cùng một tập số nguyên sẽ có tính bằng nhau về mặt cấu trúc.
Đối sánh mẫu
string Describe(DateRange range) => range switch
{
DateRange.EmptyRange => "no dates",
DateRange.Finite f => $"{f.Start:yyyy-MM-dd} to {f.End:yyyy-MM-dd}",
DateRange.UnboundedStart s => $"up to {s.End:yyyy-MM-dd}",
DateRange.UnboundedEnd e => $"from {e.Start:yyyy-MM-dd} onwards",
DateRange.Infinity => "all time",
_ => throw new UnreachableException()
};
Vì hệ phân cấp được đóng trong assembly, về lý thuyết chỉ có năm biến thể. Các phiên bản C# hiện tại vẫn yêu cầu nhánh mặc định. Cách viết phù hợp là ném UnreachableException, để thể hiện rằng việc rơi vào nhánh đó là lỗi trong mã gọi chứ không phải điều kiện hợp lệ lúc chạy. Các liên hợp phân biệt nguyên bản với kiểm tra đầy đủ khi biên dịch được định hướng cho C# 15 và .NET 11.
Các phép truy vấn: đại số khoảng đầy đủ trong bộ nhớ
var sprint = DateRange.CreateFinite(
new DateOnly(2025, 1, 6),
new DateOnly(2025, 1, 17));
sprint.Contains(new DateOnly(2025, 1, 10)); // true
sprint.Contains(new DateOnly(2025, 1, 20)); // false
var inner = DateRange.CreateFinite(
new DateOnly(2025, 1, 8),
new DateOnly(2025, 1, 14));
sprint.Contains(inner); // true
inner.IsContainedBy(sprint); // true
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(5, 10);
a.Overlaps(b); // true
var c = Int32Range.CreateFinite(6, 10);
a.IsAdjacentTo(c); // true
Với kiểu rời rạc, [1, 5] và [6, 10] là hai khoảng kề nhau vì không có số nguyên nào nằm giữa 5 và 6. Bước nhảy được định nghĩa bởi NextValueAfter và PreviousValueBefore. Với kiểu liên tục, tính kề nhau dựa trên quy tắc bao gồm bổ sung: [1.0, 5.0] và (5.0, 10.0] là kề nhau.
Phép toán tập hợp trả về đúng kiểu
Phép giao của hai phạm vi luôn có thể biểu diễn bằng một phạm vi duy nhất, có thể là Empty:
var a = Int32Range.CreateFinite(1, 10);
var b = Int32Range.CreateFinite(5, 15);
Int32Range intersection = a.Intersect(b); // [5, 10]
Int32Range none = a.Intersect(
Int32Range.CreateFinite(11, 20)); // Empty
Phép hợp và phép hiệu trả về RangeSet<TRange, T>, vì kết quả có thể là một hoặc hai phạm vi rời nhau:
var x = Int32Range.CreateFinite(1, 5);
var y = Int32Range.CreateFinite(7, 10);
x.Union(y); // { [1, 5], [7, 10] }
x.Union(Int32Range.CreateFinite(3, 8)); // { [1, 10] }
var big = Int32Range.CreateFinite(1, 10);
var remove = Int32Range.CreateFinite(4, 6);
big.Except(remove); // { [1, 3], [7, 10] }
RangeSet: đa phạm vi trong bộ nhớ
RangeSet<TRange, T> là đối trọng trong bộ nhớ của kiểu đa phạm vi từ PostgreSQL 14. Đây là một tập hợp bất biến, luôn được chuẩn hóa, gồm các phạm vi không giao nhau. Các phần tử được sắp xếp theo cận dưới, không giao nhau và không kề nhau.
using IntSet = RangeSet<Int32Range, int>;
var set = IntSet.From([
Int32Range.CreateFinite(1, 5),
Int32Range.CreateFinite(6, 10),
Int32Range.CreateFinite(20, 30)
]);
// { [1, 10], [20, 30] }
set | Int32Range.CreateFinite(11, 19); // { [1, 30] }
set & Int32Range.CreateFinite(5, 25); // { [5, 10], [20, 25] }
set - Int32Range.CreateFinite(4, 6); // { [1, 3], [7, 10], [20, 30] }
set.Complement(); // { (-∞, 0], [11, 19], [31, +∞) }
Phạm vi rỗng bị loại bỏ, các phạm vi giao nhau hoặc kề nhau được tự động gộp, còn phần tử Infinity sẽ làm cả tập hợp trở thành toàn miền. Vì luôn chuẩn hóa, hai tập hợp được tạo theo các cách khác nhau nhưng biểu diễn cùng một tập giá trị sẽ bằng nhau về cấu trúc.
Định dạng PostgreSQL và JSON
Cả sáu kiểu phạm vi và các kiểu RangeSet đều hỗ trợ đọc và ghi định dạng literal của PostgreSQL:
var range = DateRange.Parse("[2025-01-06,2025-01-17]", null);
range.ToString(); // [2025-01-06,2025-01-17]
var set = RangeSet<Int32Range, int>.Parse(
"{[1,5],[7,10]}", null);
set.ToString(); // {[1,5],[7,10]}
Nhờ vậy, phạm vi có thể đi qua migration, ADO.NET thuần, nhật ký và công cụ chẩn đoán mà không mất thông tin.
Thư viện cũng cung cấp bộ chuyển đổi System.Text.Json trong không gian tên CodoMetis.ValueRanges.Serialization:
var options = new JsonSerializerOptions()
.AddRangeConverters();
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.AddRangeConverters());
Sau khi đăng ký, phạm vi có thể đi qua endpoint ASP.NET Core dưới dạng chuỗi literal PostgreSQL, chẳng hạn "[2025-01-06,2025-01-17]".
Cầu nối EF Core: CodoMetis.ValueRanges.EFCore.PostgreSQL
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL
Gói bổ sung này kết nối mô hình miền với cơ sở dữ liệu. Nó không thay thế NpgsqlRange<T> mà sử dụng kiểu đó tại ranh giới ánh xạ của EF Core. Sau đó, các bộ dịch của Npgsql sẽ tạo ra đúng toán tử PostgreSQL.
options.UseNpgsql(connectionString,
npgsql => npgsql.UseValueRanges());
Các kiểu phạm vi và RangeSet được ánh xạ theo quy ước:
Int32Rangethànhint4rangeRangeSet<Int32Range, int>thànhint4multirangeDateRangethànhdaterangeRangeSet<DateRange, DateOnly>thànhdatemultirange
Đại số khoảng cũng được dịch từ LINQ sang SQL:
var day = new DateOnly(2025, 6, 15);
bookings.Where(b => b.Period.Contains(day));
bookings.Where(b => b.Period.Overlaps(other));
bookings.Where(b => b.Period.IsAdjacentTo(other));
bookings.Select(b => b.Period.Intersect(other));
Cùng một lời gọi phương thức chạy trong bộ nhớ ở tầng miền sẽ được dịch thành toán tử PostgreSQL tương ứng khi nằm trong truy vấn EF Core. Cùng mã, cùng ngữ nghĩa, hai môi trường thực thi.
Ví dụ hoàn chỉnh trong mô hình miền
public sealed class ShiftAssignment
{
public EmployeeId EmployeeId { get; }
public DateRange Period { get; }
public ShiftAssignment(EmployeeId employeeId, DateRange period)
{
if (period is DateRange.EmptyRange)
throw new DomainException(
"A shift must cover at least one day.");
EmployeeId = employeeId;
Period = period;
}
public bool ConflictsWith(ShiftAssignment other)
=> Period.Overlaps(other.Period);
public bool IsContiguousWith(ShiftAssignment other)
=> Period.IsAdjacentTo(other.Period)
|| Period.Overlaps(other.Period);
}
public static DateRange FindUncoveredPeriod(
DateRange requiredWindow,
IReadOnlyList<ShiftAssignment> assignments)
{
var covered = RangeSet<DateRange, DateOnly>.From(
assignments.Select(a => a.Period));
var uncoveredSet = requiredWindow.Except(covered);
return uncoveredSet.Count == 0
? DateRange.Empty
: uncoveredSet[0];
}
Tầng miền không cần biết về Npgsql hay EF Core. Gói EF Core ánh xạ ShiftAssignment.Period thành cột daterange. Logic miền chạy giống hệt trong kiểm thử đơn vị và môi trường thật: không cơ sở dữ liệu, không Npgsql, không mô phỏng trong kiểm thử; khi triển khai thật vẫn sử dụng đầy đủ PostgreSQL.
Mã nguồn và gói
- CodoMetis.ValueRanges trên NuGet
- CodoMetis.ValueRanges.EFCore.PostgreSQL trên NuGet
- Mã nguồn trên GitHub
Cả hai gói đều là mã nguồn mở theo giấy phép MIT.
Tóm lược
Mẫu hai cột, với các trường StartDate và EndDate tách rời, là một trong những nguồn phổ biến nhất của logic trùng lặp và lỗi tinh vi trong các mô hình miền có xử lý khoảng thời gian. PostgreSQL đã có câu trả lời tốt hơn từ hơn một thập kỷ, nhưng trước đây .NET thiếu cách đưa sự giàu ngữ nghĩa đó vào tầng miền mà không kéo theo trình điều khiển cơ sở dữ liệu.
CodoMetis.ValueRanges lấp đầy khoảng trống này bằng:
- Một liên hợp phân biệt cho mỗi kiểu phạm vi, gồm năm biến thể sealed với hình dạng được mã hóa trong kiểu tĩnh.
- Đối sánh mẫu trên toàn bộ biến thể, giúp thể hiện rõ mọi trường hợp cần xử lý.
- Đại số khoảng đầy đủ trong bộ nhớ: chứa, giao nhau, kề nhau, so sánh hướng, giao, hợp và hiệu.
RangeSet<TRange, T>với chuẩn hóa cấu trúc, toán tử tập hợp, phần bù và ngữ nghĩa củaIReadOnlyList<T>.- Đọc và ghi định dạng đường truyền PostgreSQL cùng khả năng tuần tự hóa bằng
System.Text.Json. - Gói bổ sung EF Core/PostgreSQL kết nối kiểu miền với
NpgsqlRange<T>tại ranh giới ánh xạ và dịch đại số khoảng từ LINQ sang SQL. - Các kiểu cơ sở abstract record là kiểu tham chiếu có chủ đích, không phải struct, vì liên hợp phân biệt có đa hình và đối sánh mẫu là khái niệm ở cấp lớp trong C#.
Nếu mô hình miền .NET của bạn làm việc với các khoảng thời gian, hãy cân nhắc thử thư viện này. Việc biến khoảng thời gian thành đối tượng miền hạng nhất giúp ranh giới trở nên rõ ràng, logic được kiểm thử dễ dàng hơn và các trạng thái không hợp lệ bị loại bỏ ngay từ thiết kế.



