實戰:RESTful API 微服務
API 專案架構 (Clean Architecture)
💡 比喻:洋蔥的分層 Clean Architecture 像洋蔥一樣層層包裹:
- 最內層(核心):Domain 實體和商業規則,不依賴任何外部套件
- 第二層:Application 層,定義 Use Case
- 第三層:Infrastructure,實作資料庫、API 等
- 最外層:Presentation(Controller),接收 HTTP 請求
關鍵原則:內層不知道外層的存在,依賴方向永遠是由外往內。
專案結構
MyApi.sln
├── src/
│ ├── MyApi.Domain/ ← 核心層:實體 + 介面
│ │ ├── Entities/
│ │ │ ├── User.cs
│ │ │ └── TodoItem.cs
│ │ └── Interfaces/
│ │ ├── IUserRepository.cs
│ │ └── ITodoRepository.cs
│ ├── MyApi.Application/ ← 應用層:Use Case + DTO
│ │ ├── DTOs/
│ │ │ ├── TodoCreateDto.cs
│ │ │ └── TodoResponseDto.cs
│ │ ├── Services/
│ │ │ └── TodoService.cs
│ │ └── Validators/
│ │ └── TodoValidator.cs
│ ├── MyApi.Infrastructure/ ← 基礎層:EF Core + 外部服務
│ │ ├── Data/
│ │ │ └── AppDbContext.cs
│ │ └── Repositories/
│ │ └── TodoRepository.cs
│ └── MyApi.Api/ ← 表現層:Controller + Middleware
│ ├── Controllers/
│ │ └── TodoController.cs
│ ├── Middleware/
│ │ └── ExceptionMiddleware.cs
│ └── Program.cs
└── tests/
└── MyApi.Tests/ ← 測試專案
├── UnitTests/
└── IntegrationTests/
Domain 層實作
// Domain 實體:TodoItem // Domain entity: TodoItem
public class TodoItem // 待辦事項實體
{
public int Id { get; set; } // 主鍵
public string Title { get; set; } = ""; // 標題
public string? Description { get; set; } // 描述
public bool IsCompleted { get; set; } = false; // 是否完成
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 建立時間
public DateTime? CompletedAt { get; set; } // 完成時間
public string UserId { get; set; } = ""; // 所屬使用者 ID
public TodoPriority Priority { get; set; } = TodoPriority.Medium; // 優先等級
public void MarkComplete() // 標記完成方法(Domain 邏輯)
{
if (IsCompleted) throw new InvalidOperationException("已經完成了"); // 防止重複標記
IsCompleted = true; // 設定為完成
CompletedAt = DateTime.UtcNow; // 記錄完成時間
}
}
public enum TodoPriority { Low, Medium, High, Urgent } // 優先等級列舉
// Repository 介面(定義在 Domain 層) // Repository interface in Domain layer
public interface ITodoRepository // 待辦事項 Repository 介面
{
Task<TodoItem?> GetByIdAsync(int id); // 依 ID 查詢
Task<List<TodoItem>> GetByUserIdAsync(string userId, bool? isCompleted = null); // 依使用者查詢
Task<TodoItem> CreateAsync(TodoItem item); // 建立
Task UpdateAsync(TodoItem item); // 更新
Task DeleteAsync(int id); // 刪除
Task<bool> ExistsAsync(int id); // 是否存在
}
Application 層:DTO 和 Service
// DTO:Data Transfer Object // DTO classes
public class TodoCreateDto // 建立待辦事項的 DTO
{
public string Title { get; set; } = ""; // 標題
public string? Description { get; set; } // 描述
public TodoPriority Priority { get; set; } = TodoPriority.Medium; // 優先等級
}
public class TodoResponseDto // 回應用的 DTO
{
public int Id { get; set; } // 主鍵
public string Title { get; set; } = ""; // 標題
public string? Description { get; set; } // 描述
public bool IsCompleted { get; set; } // 是否完成
public string Priority { get; set; } = ""; // 優先等級(字串)
public DateTime CreatedAt { get; set; } // 建立時間
public DateTime? CompletedAt { get; set; } // 完成時間
}
// Application Service // Application service
public class TodoService // 待辦事項服務
{
private readonly ITodoRepository _repo; // Repository
public TodoService(ITodoRepository repo) // 建構函式注入
{
_repo = repo; // 儲存 Repository
}
public async Task<TodoResponseDto> CreateAsync(string userId, TodoCreateDto dto) // 建立待辦
{
var item = new TodoItem // 從 DTO 建立實體
{
Title = dto.Title, // 設定標題
Description = dto.Description, // 設定描述
Priority = dto.Priority, // 設定優先等級
UserId = userId // 設定所屬使用者
};
var created = await _repo.CreateAsync(item); // 透過 Repository 建立
return MapToDto(created); // 轉為 DTO 回傳
}
public async Task<List<TodoResponseDto>> GetUserTodosAsync( // 取得使用者的待辦
string userId, bool? isCompleted = null) // 可選篩選條件
{
var items = await _repo.GetByUserIdAsync(userId, isCompleted); // 查詢資料
return items.Select(MapToDto).ToList(); // 轉為 DTO 清單
}
public async Task CompleteAsync(int id, string userId) // 完成待辦
{
var item = await _repo.GetByIdAsync(id); // 查詢待辦
if (item == null) throw new KeyNotFoundException("找不到此待辦事項"); // 不存在就報錯
if (item.UserId != userId) throw new UnauthorizedAccessException("無權操作"); // 不是自己的就拒絕
item.MarkComplete(); // 呼叫 Domain 方法
await _repo.UpdateAsync(item); // 儲存更新
}
private static TodoResponseDto MapToDto(TodoItem item) => new() // 實體轉 DTO
{
Id = item.Id, // 對應 ID
Title = item.Title, // 對應標題
Description = item.Description, // 對應描述
IsCompleted = item.IsCompleted, // 對應完成狀態
Priority = item.Priority.ToString(), // 列舉轉字串
CreatedAt = item.CreatedAt, // 對應建立時間
CompletedAt = item.CompletedAt // 對應完成時間
};
}
JWT 身份驗證完整實作
💡 比喻:遊樂園手環 JWT 就像遊樂園的手環——入場時蓋章(登入取得 Token), 之後每個設施只要看你的手環(驗證 Token)就讓你進。 你不用每次都回售票亭買票(重新登入)。
// JWT 設定 // JWT configuration in Program.cs
builder.Services.AddAuthentication(options => // 設定驗證方案
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; // 預設使用 JWT
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; // 挑戰也用 JWT
})
.AddJwtBearer(options => // 設定 JWT Bearer
{
options.TokenValidationParameters = new TokenValidationParameters // 設定驗證參數
{
ValidateIssuer = true, // 驗證發行者
ValidateAudience = true, // 驗證接收者
ValidateLifetime = true, // 驗證有效期限
ValidateIssuerSigningKey = true, // 驗證簽名金鑰
ValidIssuer = builder.Configuration["Jwt:Issuer"], // 有效發行者
ValidAudience = builder.Configuration["Jwt:Audience"], // 有效接收者
IssuerSigningKey = new SymmetricSecurityKey( // 簽名金鑰
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)), // 從設定檔讀取
ClockSkew = TimeSpan.Zero // 不允許時間偏差
};
});
// JWT Token 產生服務 // JWT token generation service
public class JwtService // JWT 服務類別
{
private readonly IConfiguration _config; // 設定檔
public JwtService(IConfiguration config) // 建構函式
{
_config = config; // 儲存設定
}
public string GenerateToken(User user) // 產生 Token 方法
{
var claims = new List<Claim> // 建立 Claims 清單
{
new(ClaimTypes.NameIdentifier, user.Id), // 使用者 ID
new(ClaimTypes.Email, user.Email), // Email
new(ClaimTypes.Name, user.UserName), // 使用者名稱
new(ClaimTypes.Role, user.Role), // 角色
new("jti", Guid.NewGuid().ToString()) // JWT ID(唯一識別碼)
};
var key = new SymmetricSecurityKey( // 建立簽名金鑰
Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); // 從設定讀取密鑰
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); // 設定簽名演算法
var token = new JwtSecurityToken( // 建立 JWT Token
issuer: _config["Jwt:Issuer"], // 發行者
audience: _config["Jwt:Audience"], // 接收者
claims: claims, // Claims
expires: DateTime.UtcNow.AddHours(2), // 2 小時後過期
signingCredentials: creds // 簽名憑證
);
return new JwtSecurityTokenHandler().WriteToken(token); // 序列化為字串
}
public string GenerateRefreshToken() // 產生 Refresh Token
{
var randomBytes = new byte[64]; // 建立 64 bytes 陣列
using var rng = RandomNumberGenerator.Create(); // 建立安全亂數產生器
rng.GetBytes(randomBytes); // 填入隨機位元組
return Convert.ToBase64String(randomBytes); // 轉為 Base64 字串
}
}
// 登入 API Controller // Auth controller
[ApiController] // 標記為 API Controller
[Route("api/[controller]")] // 路由:api/auth
public class AuthController : ControllerBase // 繼承 ControllerBase
{
private readonly UserManager<User> _userManager; // Identity 使用者管理
private readonly JwtService _jwtService; // JWT 服務
public AuthController(UserManager<User> userManager, JwtService jwtService) // 建構函式
{
_userManager = userManager; // 儲存 UserManager
_jwtService = jwtService; // 儲存 JWT 服務
}
[HttpPost("login")] // POST api/auth/login
public async Task<IActionResult> Login([FromBody] LoginDto dto) // 登入 Action
{
var user = await _userManager.FindByEmailAsync(dto.Email); // 用 Email 找使用者
if (user == null) return Unauthorized(new { message = "帳號或密碼錯誤" }); // 找不到
var isValid = await _userManager.CheckPasswordAsync(user, dto.Password); // 驗證密碼
if (!isValid) return Unauthorized(new { message = "帳號或密碼錯誤" }); // 密碼錯誤
var token = _jwtService.GenerateToken(user); // 產生 JWT Token
var refreshToken = _jwtService.GenerateRefreshToken(); // 產生 Refresh Token
user.RefreshToken = refreshToken; // 儲存 Refresh Token
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); // 設定 7 天有效期
await _userManager.UpdateAsync(user); // 更新使用者資料
return Ok(new // 回傳 Token
{
token, // JWT Token
refreshToken, // Refresh Token
expiresIn = 7200 // 有效秒數
});
}
}
Swagger/OpenAPI 文件
// Program.cs 設定 Swagger // Configure Swagger in Program.cs
builder.Services.AddSwaggerGen(options => // 加入 Swagger 產生器
{
options.SwaggerDoc("v1", new OpenApiInfo // 設定文件資訊
{
Title = "MyApi", // API 標題
Version = "v1", // 版本號
Description = "我的 RESTful API 服務", // 描述
Contact = new OpenApiContact // 聯絡資訊
{
Name = "開發者", // 聯絡人名稱
Email = "dev@example.com" // 聯絡 Email
}
});
// 設定 JWT 驗證 // Configure JWT auth in Swagger
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme // 定義安全機制
{
Name = "Authorization", // Header 名稱
Type = SecuritySchemeType.Http, // 類型:HTTP
Scheme = "bearer", // 方案:bearer
BearerFormat = "JWT", // 格式:JWT
In = ParameterLocation.Header, // 位置:Header
Description = "請輸入 JWT Token" // 說明
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement // 設定安全需求
{
{
new OpenApiSecurityScheme // 參考定義
{
Reference = new OpenApiReference // 參考
{
Type = ReferenceType.SecurityScheme, // 類型
Id = "Bearer" // 參考 ID
}
},
Array.Empty<string>() // 不需要額外的 scopes
}
});
// 載入 XML 文件註解 // Include XML comments
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; // XML 檔名
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); // XML 路徑
if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath); // 載入註解
});
API 版本控制
// 設定 API 版本控制 // Configure API versioning
builder.Services.AddApiVersioning(options => // 加入版本控制服務
{
options.DefaultApiVersion = new ApiVersion(1, 0); // 預設版本 1.0
options.AssumeDefaultVersionWhenUnspecified = true; // 未指定時用預設版本
options.ReportApiVersions = true; // 在回應 Header 報告版本
options.ApiVersionReader = ApiVersionReader.Combine( // 組合多種版本讀取器
new UrlSegmentApiVersionReader(), // 從 URL 讀取:/api/v1/todos
new HeaderApiVersionReader("X-Api-Version"), // 從 Header 讀取
new QueryStringApiVersionReader("api-version")); // 從 Query String 讀取
});
// V1 Controller // Version 1 controller
[ApiController] // API Controller
[ApiVersion("1.0")] // 版本 1.0
[Route("api/v{version:apiVersion}/[controller]")] // 路由包含版本號
public class TodosV1Controller : ControllerBase // V1 Controller
{
[HttpGet] // GET api/v1/todos
public async Task<IActionResult> GetAll() // 取得所有待辦(V1)
{
var items = await _service.GetUserTodosAsync(UserId); // 查詢待辦
return Ok(items); // 回傳 V1 格式
}
}
// V2 Controller(加入分頁) // Version 2 controller with pagination
[ApiController] // API Controller
[ApiVersion("2.0")] // 版本 2.0
[Route("api/v{version:apiVersion}/[controller]")] // 路由包含版本號
public class TodosV2Controller : ControllerBase // V2 Controller
{
[HttpGet] // GET api/v2/todos?page=1&pageSize=10
public async Task<IActionResult> GetAll( // 取得所有待辦(V2,含分頁)
[FromQuery] int page = 1, // 頁碼參數
[FromQuery] int pageSize = 10) // 每頁筆數參數
{
var result = await _service.GetPagedAsync(UserId, page, pageSize); // 查詢分頁結果
return Ok(new // 回傳 V2 格式(含分頁資訊)
{
data = result.Items, // 資料
pagination = new // 分頁資訊
{
currentPage = page, // 目前頁碼
pageSize, // 每頁筆數
totalItems = result.TotalCount, // 總筆數
totalPages = result.TotalPages // 總頁數
}
});
}
}
中間件管線設計
// 全域例外處理中間件 // Global exception handling middleware
public class ExceptionMiddleware // 例外處理中間件
{
private readonly RequestDelegate _next; // 下一個中間件
private readonly ILogger<ExceptionMiddleware> _logger; // 日誌記錄器
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger) // 建構函式
{
_next = next; // 儲存下一個中間件
_logger = logger; // 儲存日誌記錄器
}
public async Task InvokeAsync(HttpContext context) // 中間件執行方法
{
try // 嘗試執行後續管線
{
await _next(context); // 執行下一個中間件
}
catch (KeyNotFoundException ex) // 捕捉找不到的例外
{
_logger.LogWarning(ex, "資源不存在"); // 記錄警告
context.Response.StatusCode = 404; // 設定 404 狀態碼
await context.Response.WriteAsJsonAsync(new // 回傳 JSON 錯誤
{
status = 404, // 狀態碼
message = ex.Message // 錯誤訊息
});
}
catch (UnauthorizedAccessException ex) // 捕捉未授權例外
{
_logger.LogWarning(ex, "未授權的存取"); // 記錄警告
context.Response.StatusCode = 403; // 設定 403 狀態碼
await context.Response.WriteAsJsonAsync(new // 回傳 JSON 錯誤
{
status = 403, // 狀態碼
message = "您沒有權限執行此操作" // 錯誤訊息
});
}
catch (Exception ex) // 捕捉所有其他例外
{
_logger.LogError(ex, "未預期的錯誤"); // 記錄錯誤
context.Response.StatusCode = 500; // 設定 500 狀態碼
await context.Response.WriteAsJsonAsync(new // 回傳 JSON 錯誤
{
status = 500, // 狀態碼
message = "伺服器發生錯誤,請稍後再試" // 不要暴露內部錯誤
});
}
}
}
// 請求計時中間件 // Request timing middleware
public class RequestTimingMiddleware // 請求計時中間件
{
private readonly RequestDelegate _next; // 下一個中間件
private readonly ILogger<RequestTimingMiddleware> _logger; // 日誌記錄器
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger) // 建構函式
{
_next = next; // 儲存下一個中間件
_logger = logger; // 儲存日誌記錄器
}
public async Task InvokeAsync(HttpContext context) // 中間件執行方法
{
var sw = Stopwatch.StartNew(); // 開始計時
await _next(context); // 執行後續管線
sw.Stop(); // 停止計時
var elapsed = sw.ElapsedMilliseconds; // 取得經過毫秒數
if (elapsed > 500) // 如果超過 500ms
{
_logger.LogWarning("慢請求:{Method} {Path} 花了 {Elapsed}ms", // 記錄慢請求
context.Request.Method, context.Request.Path, elapsed); // 記錄方法、路徑、時間
}
context.Response.Headers["X-Response-Time"] = $"{elapsed}ms"; // 加入回應時間 Header
}
}
// 在 Program.cs 中註冊中間件順序 // Register middleware pipeline in Program.cs
app.UseMiddleware<RequestTimingMiddleware>(); // 1. 請求計時(最外層)
app.UseMiddleware<ExceptionMiddleware>(); // 2. 例外處理
app.UseAuthentication(); // 3. 驗證
app.UseAuthorization(); // 4. 授權
app.MapControllers(); // 5. 路由到 Controller
Docker 容器化 + docker-compose
// Dockerfile // Dockerfile for .NET API
// --- 以下是 Dockerfile 的內容範例 --- // --- Dockerfile content example ---
// 多階段建構:第一階段(建置) // Multi-stage build: build stage
// FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build // 使用 .NET SDK 映像
// WORKDIR /src // 設定工作目錄
// COPY *.sln . // 複製方案檔
// COPY src/MyApi.Api/*.csproj src/MyApi.Api/ // 複製專案檔
// COPY src/MyApi.Domain/*.csproj src/MyApi.Domain/ // 複製 Domain 專案檔
// COPY src/MyApi.Application/*.csproj src/MyApi.Application/ // 複製 Application 專案檔
// COPY src/MyApi.Infrastructure/*.csproj src/MyApi.Infrastructure/ // 複製 Infrastructure 專案檔
// RUN dotnet restore // 還原 NuGet 套件
// COPY . . // 複製所有檔案
// RUN dotnet publish src/MyApi.Api -c Release -o /app // 發佈為 Release 版本
// 第二階段(執行) // Runtime stage
// FROM mcr.microsoft.com/dotnet/aspnet:8.0 // 使用 ASP.NET Runtime 映像
// WORKDIR /app // 設定工作目錄
// COPY --from=build /app . // 從建置階段複製產出
// EXPOSE 8080 // 開放 8080 埠
// ENTRYPOINT ["dotnet", "MyApi.Api.dll"] // 啟動指令
// docker-compose.yml 對應的 C# 設定 // Docker compose related C# config
// 在 Program.cs 中讀取容器環境變數 // Read container env vars in Program.cs
var dbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; // 資料庫主機
var dbPort = Environment.GetEnvironmentVariable("DB_PORT") ?? "5432"; // 資料庫埠
var dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "myapi"; // 資料庫名稱
var dbUser = Environment.GetEnvironmentVariable("DB_USER") ?? "postgres"; // 資料庫使用者
var dbPass = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? "password"; // 資料庫密碼
var connStr = $"Host={dbHost};Port={dbPort};Database={dbName};" + // 組合連線字串
$"Username={dbUser};Password={dbPass}"; // 加上帳密
builder.Services.AddDbContext<AppDbContext>(options => // 設定 DbContext
options.UseNpgsql(connStr)); // 使用 PostgreSQL
// 健康檢查端點(給 docker-compose 的 healthcheck 用) // Health check endpoint
builder.Services.AddHealthChecks() // 加入健康檢查服務
.AddNpgSql(connStr, name: "postgresql"); // 加入 PostgreSQL 健康檢查
app.MapHealthChecks("/health"); // 對應到 /health 端點
docker-compose.yml 說明
// docker-compose.yml 中的服務對應 // Services in docker-compose.yml
// 用 C# 物件來理解結構 // Understand structure using C# objects
public class DockerComposeService // Docker Compose 服務類別
{
public string Name { get; set; } = ""; // 服務名稱
public string Image { get; set; } = ""; // 映像來源
public List<string> Ports { get; set; } = new(); // 埠對應
public Dictionary<string, string> Environment { get; set; } = new(); // 環境變數
public List<string> DependsOn { get; set; } = new(); // 依賴的服務
}
var services = new List<DockerComposeService> // Docker Compose 服務清單
{
new() // API 服務
{
Name = "api", // 服務名稱
Image = "build: .", // 從 Dockerfile 建置
Ports = new() { "8080:8080" }, // 埠對應
Environment = new() // 環境變數
{
["DB_HOST"] = "db", // 資料庫主機(用服務名稱)
["DB_PORT"] = "5432", // 資料庫埠
["DB_NAME"] = "myapi", // 資料庫名稱
["DB_USER"] = "postgres", // 使用者
["DB_PASSWORD"] = "postgres", // 密碼
["Jwt__Key"] = "your-super-secret-key-at-least-32-chars", // JWT 金鑰
},
DependsOn = new() { "db" } // 依賴資料庫服務
},
new() // PostgreSQL 服務
{
Name = "db", // 服務名稱
Image = "postgres:16", // PostgreSQL 映像
Ports = new() { "5432:5432" }, // 埠對應
Environment = new() // 環境變數
{
["POSTGRES_DB"] = "myapi", // 預設資料庫
["POSTGRES_USER"] = "postgres", // 預設使用者
["POSTGRES_PASSWORD"] = "postgres" // 預設密碼
}
}
};
整合測試 (WebApplicationFactory)
// 整合測試基底類別 // Integration test base class
public class ApiTestBase : IClassFixture<WebApplicationFactory<Program>> // 測試基底
{
protected readonly HttpClient _client; // HTTP 客戶端
protected readonly WebApplicationFactory<Program> _factory; // Web 應用程式工廠
public ApiTestBase(WebApplicationFactory<Program> factory) // 建構函式
{
_factory = factory.WithWebHostBuilder(builder => // 自訂 Web Host
{
builder.ConfigureServices(services => // 設定測試用的服務
{
// 移除正式的 DbContext // Remove production DbContext
var descriptor = services.SingleOrDefault( // 找到 DbContext 的註冊
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>)); // 比對型別
if (descriptor != null) services.Remove(descriptor); // 移除它
// 改用 In-Memory 資料庫 // Use in-memory database
services.AddDbContext<AppDbContext>(options => // 重新註冊 DbContext
{
options.UseInMemoryDatabase("TestDb"); // 使用記憶體資料庫
});
});
});
_client = _factory.CreateClient(); // 建立測試用的 HTTP 客戶端
}
protected async Task<string> GetTokenAsync() // 取得測試用的 JWT Token
{
var loginDto = new { Email = "test@test.com", Password = "Test1234!" }; // 測試帳號
var response = await _client.PostAsJsonAsync("/api/auth/login", loginDto); // 登入
var result = await response.Content.ReadFromJsonAsync<LoginResponse>(); // 讀取回應
return result!.Token; // 回傳 Token
}
protected void SetAuthHeader(string token) // 設定驗證 Header
{
_client.DefaultRequestHeaders.Authorization = // 設定 Authorization Header
new AuthenticationHeaderValue("Bearer", token); // Bearer Token
}
}
// Todo API 整合測試 // Todo API integration tests
public class TodoApiTests : ApiTestBase // 繼承測試基底
{
public TodoApiTests(WebApplicationFactory<Program> factory) : base(factory) { } // 建構函式
[Fact] // 標記為測試方法
public async Task CreateTodo_WithValidData_ReturnsCreated() // 測試:有效資料回傳 201
{
// Arrange // 準備
var token = await GetTokenAsync(); // 取得 Token
SetAuthHeader(token); // 設定驗證
var dto = new { Title = "寫測試", Priority = "High" }; // 建立測試資料
// Act // 執行
var response = await _client.PostAsJsonAsync("/api/v1/todos", dto); // 發送 POST 請求
// Assert // 驗證
Assert.Equal(HttpStatusCode.Created, response.StatusCode); // 狀態碼應為 201
var todo = await response.Content.ReadFromJsonAsync<TodoResponseDto>(); // 讀取回應
Assert.Equal("寫測試", todo!.Title); // 標題應該正確
Assert.False(todo.IsCompleted); // 預設未完成
}
[Fact] // 標記為測試方法
public async Task GetTodos_WithoutAuth_ReturnsUnauthorized() // 測試:未驗證回傳 401
{
// Arrange // 準備(不設定 Token)
_client.DefaultRequestHeaders.Authorization = null; // 清除驗證 Header
// Act // 執行
var response = await _client.GetAsync("/api/v1/todos"); // 發送 GET 請求
// Assert // 驗證
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // 應回傳 401
}
[Fact] // 標記為測試方法
public async Task CompleteTodo_OwnedByUser_ReturnsOk() // 測試:完成自己的待辦
{
// Arrange // 準備
var token = await GetTokenAsync(); // 取得 Token
SetAuthHeader(token); // 設定驗證
var createResponse = await _client.PostAsJsonAsync("/api/v1/todos", // 先建立一個待辦
new { Title = "測試完成功能" }); // 設定標題
var created = await createResponse.Content.ReadFromJsonAsync<TodoResponseDto>(); // 讀取建立結果
// Act // 執行
var response = await _client.PatchAsync( // 發送 PATCH 請求
$"/api/v1/todos/{created!.Id}/complete", null); // 完成待辦
// Assert // 驗證
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // 應回傳 200
}
}
GitHub Actions CI/CD
// GitHub Actions 工作流程對應的 C# 概念 // GitHub Actions workflow concept
// .github/workflows/ci-cd.yml // CI/CD configuration file
// 用 C# 物件理解 CI/CD 流程 // Understand CI/CD using C# objects
public class CiCdPipeline // CI/CD 管線類別
{
public string Name { get; set; } = "CI/CD Pipeline"; // 管線名稱
public List<string> Triggers { get; set; } = new() { "push to main", "pull request" }; // 觸發條件
public List<PipelineJob> Jobs { get; set; } = new() // 工作清單
{
new PipelineJob // 第一個工作:建置與測試
{
Name = "build-and-test", // 工作名稱
RunsOn = "ubuntu-latest", // 執行環境
Steps = new() // 步驟清單
{
"Checkout code (actions/checkout@v4)", // 步驟 1:取出程式碼
"Setup .NET 8 (actions/setup-dotnet@v4)", // 步驟 2:安裝 .NET
"Restore dependencies (dotnet restore)", // 步驟 3:還原套件
"Build (dotnet build --no-restore)", // 步驟 4:編譯
"Run tests (dotnet test --no-build)", // 步驟 5:執行測試
}
},
new PipelineJob // 第二個工作:部署
{
Name = "deploy", // 工作名稱
RunsOn = "ubuntu-latest", // 執行環境
DependsOn = "build-and-test", // 依賴建置工作
Condition = "main branch only", // 只在 main 分支執行
Steps = new() // 步驟清單
{
"Login to Docker Hub", // 步驟 1:登入 Docker Hub
"Build Docker image", // 步驟 2:建置 Docker 映像
"Push to registry", // 步驟 3:推送到 Registry
"Deploy to server", // 步驟 4:部署到伺服器
}
}
};
}
public class PipelineJob // 工作類別
{
public string Name { get; set; } = ""; // 工作名稱
public string RunsOn { get; set; } = ""; // 執行環境
public string? DependsOn { get; set; } // 依賴的工作
public string? Condition { get; set; } // 執行條件
public List<string> Steps { get; set; } = new(); // 步驟清單
}
// 在測試中驗證 CI/CD 所需的健康檢查 // Health check for CI/CD
[Fact] // 標記為測試方法
public async Task HealthCheck_ReturnsHealthy() // 健康檢查測試
{
var response = await _client.GetAsync("/health"); // 發送健康檢查請求
Assert.Equal(HttpStatusCode.OK, response.StatusCode); // 應回傳 200
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:JWT 金鑰寫在程式碼裡
// ❌ 錯誤:金鑰硬編碼 // Mistake: hardcoded secret key
var key = new SymmetricSecurityKey( // 建立金鑰
Encoding.UTF8.GetBytes("my-secret-key-12345")); // 金鑰直接寫在程式碼裡!
// 這個金鑰會被 commit 到 Git,所有人都看得到 // This key is visible in Git!
// ✅ 正確:從環境變數或設定檔讀取 // Correct: read from environment
var key = new SymmetricSecurityKey( // 建立金鑰
Encoding.UTF8.GetBytes( // 從設定讀取
builder.Configuration["Jwt:Key"] // 從 appsettings 讀
?? Environment.GetEnvironmentVariable("JWT_KEY") // 或從環境變數讀
?? throw new InvalidOperationException("JWT Key 未設定"))); // 都沒有就報錯
❌ 錯誤 2:API 回傳 Entity 而不是 DTO
// ❌ 錯誤:直接回傳資料庫 Entity // Mistake: returning DB entity directly
[HttpGet("{id}")] // GET api/todos/5
public async Task<IActionResult> Get(int id) // 取得待辦
{
var todo = await _context.Todos // 查詢資料庫
.Include(t => t.User) // 載入使用者(包含密碼 Hash!)
.FirstOrDefaultAsync(t => t.Id == id); // 找到資料
return Ok(todo); // 回傳含有敏感資料的 Entity
// 問題:User 物件裡的 PasswordHash 也被序列化送出去了! // Bug: PasswordHash leaked!
}
// ✅ 正確:回傳 DTO // Correct: return DTO
[HttpGet("{id}")] // GET api/todos/5
public async Task<IActionResult> Get(int id) // 取得待辦
{
var dto = await _service.GetByIdAsync(id); // 透過 Service 取得 DTO
if (dto == null) return NotFound(); // 找不到回 404
return Ok(dto); // 回傳只包含需要欄位的 DTO
}
❌ 錯誤 3:沒有做輸入驗證
// ❌ 錯誤:直接信任使用者輸入 // Mistake: trusting user input
[HttpPost] // POST api/todos
public async Task<IActionResult> Create([FromBody] TodoCreateDto dto) // 建立待辦
{
// 沒有任何驗證就直接存入資料庫 // No validation at all!
var item = new TodoItem { Title = dto.Title }; // 直接用輸入值
_context.Todos.Add(item); // 直接存入
await _context.SaveChangesAsync(); // 儲存
return Ok(item); // 回傳
}
// ✅ 正確:做好輸入驗證 // Correct: validate input
[HttpPost] // POST api/todos
public async Task<IActionResult> Create([FromBody] TodoCreateDto dto) // 建立待辦
{
if (!ModelState.IsValid) // 檢查 Model 驗證
return BadRequest(ModelState); // 驗證失敗回 400
if (string.IsNullOrWhiteSpace(dto.Title)) // 額外檢查標題
return BadRequest(new { message = "標題不可為空" }); // 空白也不行
if (dto.Title.Length > 200) // 檢查長度
return BadRequest(new { message = "標題不可超過 200 字" }); // 太長也不行
var result = await _service.CreateAsync(UserId, dto); // 透過 Service 建立
return CreatedAtAction(nameof(Get), new { id = result.Id }, result); // 回傳 201
}
📋 本章重點
| 主題 | 關鍵要點 | 工具/套件 |
|---|---|---|
| Clean Architecture | 依賴方向由外往內 | 多專案分層 |
| JWT 驗證 | Token + Refresh Token | Microsoft.AspNetCore.Authentication.JwtBearer |
| Swagger | API 文件自動產生 | Swashbuckle.AspNetCore |
| API 版本控制 | URL / Header / Query | Asp.Versioning.Mvc |
| 中間件 | 例外處理 + 計時 | 自訂 Middleware |
| Docker | 多階段建構 | Dockerfile + docker-compose |
| 整合測試 | In-Memory DB 測試 | WebApplicationFactory |
| CI/CD | 自動建置 + 部署 | GitHub Actions |
🎯 恭喜完成所有專案章節! 你現在有能力從零開始規劃並建構完整的 .NET 應用程式了!