☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
專案實戰 進階

實戰: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 應用程式了!

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊