多人連線與 Session 管理
同步 vs 非同步處理
💡 比喻:點餐方式
- 同步處理:你在櫃台點餐,站在那裡等做好才離開。後面的人都在排隊等你。
- 非同步處理:你在櫃台點餐,拿到號碼牌就去坐下。櫃台可以繼續服務下一位。
非同步不是「更快」,而是「不浪費等待時間」。 做菜(I/O 操作)的時間沒有變短,但櫃台(執行緒)可以服務更多人。
同步 vs 非同步程式碼
// ❌ 同步寫法:執行緒在等待資料庫回應時被卡住
public IActionResult GetUsers() // 同步方法,沒有 async
{
var users = _db.Users.ToList(); // 等待資料庫回應(執行緒被佔用)
return Ok(users); // 資料庫回應後才執行
}
// 問題:如果有 100 個請求同時來,需要 100 個執行緒
// 執行緒是有限的資源,用完就會出現 503 錯誤
// ✅ 非同步寫法:執行緒在等待時可以去處理其他請求
public async Task<IActionResult> GetUsers() // 非同步方法,回傳 Task
{
var users = await _db.Users.ToListAsync(); // await 等待時釋放執行緒
return Ok(users); // 資料庫回應後繼續
}
// 好處:100 個請求可能只需要 10 幾個執行緒就能處理
// 因為大部分時間都在等 I/O,執行緒可以重複利用
效能差異圖解
// 同步模式:4 個執行緒處理 4 個請求
Thread 1: [===處理===][等待DB..........][===完成===] // 執行緒被卡住
Thread 2: [===處理===][等待DB..........][===完成===] // 無法服務新請求
Thread 3: [===處理===][等待DB..........][===完成===] // 資源浪費
Thread 4: [===處理===][等待DB..........][===完成===] // 全部都在等
Request 5: 排隊中...等不到執行緒...超時...503 錯誤 // 第 5 個請求失敗
// 非同步模式:2 個執行緒處理 4+ 個請求
Thread 1: [處理1][等1→處理3][等3→處理5][完成1][完成3][完成5] // 一個執行緒輪流處理
Thread 2: [處理2][等2→處理4][完成2][完成4] // 充分利用等待時間
// 少量執行緒就能處理大量請求!
Connection Pool 概念
💡 比喻:共用腳踏車
- 沒有 Connection Pool:每次出門都買一台新腳踏車,用完丟掉(太浪費!)
- 有 Connection Pool:社區有一批公共腳踏車,需要時借,用完歸還
建立資料庫連線很花時間(像買腳踏車),Connection Pool 讓你重複使用已建立的連線。
資料庫 Connection Pool
// 連線字串中設定 Connection Pool 參數
var connectionString = new StringBuilder() // 建立連線字串
.Append("Server=localhost;") // 資料庫伺服器位址
.Append("Database=MyDb;") // 資料庫名稱
.Append("Min Pool Size=5;") // 最少保持 5 個連線
.Append("Max Pool Size=100;") // 最多建立 100 個連線
.Append("Connection Timeout=30;") // 等待連線的逾時時間(秒)
.Append("Connection Lifetime=300;") // 連線存活最長時間(秒)
.ToString(); // 轉成字串
// 在 Program.cs 中註冊 DbContext
builder.Services.AddDbContext<AppDbContext>(options => // 註冊資料庫上下文
options.UseSqlServer(connectionString) // 使用 SQL Server
);
// EF Core 會自動管理 Connection Pool
// 取得連線 → 使用 → 歸還,不需要手動管理
HttpClient Connection Pool
// ❌ 錯誤做法:每次都 new HttpClient
public async Task<string> CallApi() // 呼叫外部 API
{
using var client = new HttpClient(); // 每次 new 會建立新連線
return await client.GetStringAsync("https://api.example.com"); // 用完就丟
}
// 問題:頻繁建立/關閉連線會耗盡 Socket(Socket Exhaustion)
// ✅ 正確做法:使用 IHttpClientFactory
// 在 Program.cs 中註冊
builder.Services.AddHttpClient("ExternalApi", client => // 註冊具名 HttpClient
{
client.BaseAddress = new Uri("https://api.example.com"); // 基底位址
client.Timeout = TimeSpan.FromSeconds(30); // 逾時設定
client.DefaultRequestHeaders.Add("Accept", "application/json"); // 預設標頭
});
// 在 Service 中使用
public class MyService // 服務類別
{
private readonly IHttpClientFactory _factory; // HttpClient 工廠
public MyService(IHttpClientFactory factory) // 透過 DI 注入
{
_factory = factory; // 儲存工廠實例
}
public async Task<string> CallApi() // 呼叫 API
{
var client = _factory.CreateClient("ExternalApi"); // 從工廠取得 HttpClient
return await client.GetStringAsync("/data"); // 連線會被自動管理和重用
}
}
Session vs Token 狀態管理
💡 比喻:身分識別方式
- Session:像是遊樂園的手環——入園時套在手上,工作人員看到手環就知道你買過票。但手環資訊存在遊樂園的系統裡。
- Token (JWT):像是護照——你自己帶著,上面寫著你的身分資訊。任何人都能讀取,但偽造不了(有防偽鋼印)。
Session 方式
// Program.cs 設定 Session
builder.Services.AddDistributedMemoryCache(); // 記憶體快取(開發用)
builder.Services.AddSession(options => // 設定 Session 選項
{
options.IdleTimeout = TimeSpan.FromMinutes(30); // 閒置 30 分鐘過期
options.Cookie.HttpOnly = true; // Cookie 只能透過 HTTP 存取
options.Cookie.IsEssential = true; // 標記為必要 Cookie
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // 只在 HTTPS 傳送
});
var app = builder.Build(); // 建構應用程式
app.UseSession(); // 啟用 Session 中介軟體
// 在 Controller 中使用 Session
public class CartController : Controller // 購物車控制器
{
public IActionResult AddItem(int productId) // 加入商品
{
var cart = HttpContext.Session.GetString("Cart"); // 從 Session 取得購物車
// 處理購物車邏輯... // 業務邏輯
HttpContext.Session.SetString("Cart", updatedCart); // 更新 Session
return Ok(); // 回傳成功
}
}
JWT Token 方式
// Program.cs 設定 JWT 驗證
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) // 設定 JWT 驗證
.AddJwtBearer(options => // JWT Bearer 選項
{
options.TokenValidationParameters = new TokenValidationParameters // 驗證參數
{
ValidateIssuer = true, // 驗證發行者
ValidateAudience = true, // 驗證對象
ValidateLifetime = true, // 驗證有效期限
ValidateIssuerSigningKey = true, // 驗證簽名金鑰
ValidIssuer = "myapp.com", // 合法的發行者
ValidAudience = "myapp.com", // 合法的對象
IssuerSigningKey = new SymmetricSecurityKey( // 簽名金鑰
Encoding.UTF8.GetBytes("YourSuperSecretKey123!")) // 金鑰字串(生產環境要用環境變數)
};
});
// 產生 JWT Token
public string GenerateToken(User user) // 產生 Token 方法
{
var claims = new[] // 宣告(Token 中的資訊)
{
new Claim(ClaimTypes.Name, user.Username), // 使用者名稱
new Claim(ClaimTypes.Role, user.Role), // 使用者角色
new Claim("UserId", user.Id.ToString()) // 使用者 ID
};
var key = new SymmetricSecurityKey( // 建立簽名金鑰
Encoding.UTF8.GetBytes("YourSuperSecretKey123!") // 金鑰字串
);
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); // 簽名憑證
var token = new JwtSecurityToken( // 建立 JWT Token
issuer: "myapp.com", // 發行者
audience: "myapp.com", // 對象
claims: claims, // 宣告內容
expires: DateTime.Now.AddHours(1), // 1 小時後過期
signingCredentials: creds // 簽名憑證
);
return new JwtSecurityTokenHandler().WriteToken(token); // 序列化為字串
}
WebSocket 長連線
💡 比喻:打電話 vs 傳簡訊
- HTTP:像傳簡訊——每次都要重新撥號、說完就掛斷
- WebSocket:像打電話——接通後保持連線,雙方可以隨時說話
適合需要即時更新的場景:聊天室、股票報價、線上遊戲、通知推播。
SignalR(ASP.NET Core 的 WebSocket 框架)
// ChatHub.cs - 定義 SignalR Hub
using Microsoft.AspNetCore.SignalR; // SignalR 命名空間
public class ChatHub : Hub // 繼承 Hub 基底類別
{
// 當客戶端連線時觸發
public override async Task OnConnectedAsync() // 連線事件
{
var username = Context.User?.Identity?.Name; // 取得使用者名稱
await Clients.All.SendAsync( // 通知所有人
"UserJoined", $"{username} 加入了聊天室" // 發送加入訊息
);
await base.OnConnectedAsync(); // 呼叫基底方法
}
// 當客戶端發送訊息時觸發
public async Task SendMessage(string message) // 接收客戶端的訊息
{
var username = Context.User?.Identity?.Name; // 取得發送者名稱
await Clients.All.SendAsync( // 廣播給所有連線的客戶端
"ReceiveMessage", username, message // 傳送使用者名稱和訊息內容
);
}
// 發送訊息給特定群組
public async Task SendToGroup(string group, string msg) // 群組訊息
{
await Clients.Group(group).SendAsync( // 只發給特定群組
"ReceiveMessage", Context.User?.Identity?.Name, msg // 訊息內容
);
}
// 加入群組
public async Task JoinGroup(string groupName) // 加入群組方法
{
await Groups.AddToGroupAsync( // 將連線加入群組
Context.ConnectionId, groupName // 使用連線 ID 和群組名稱
);
await Clients.Group(groupName).SendAsync( // 通知群組成員
"UserJoined", $"{Context.User?.Identity?.Name} 加入了 {groupName}"
);
}
// 當客戶端斷線時觸發
public override async Task OnDisconnectedAsync(Exception? ex) // 斷線事件
{
await Clients.All.SendAsync( // 通知所有人
"UserLeft", $"{Context.User?.Identity?.Name} 離開了" // 離開訊息
);
await base.OnDisconnectedAsync(ex); // 呼叫基底方法
}
}
// Program.cs 中註冊 SignalR
var builder = WebApplication.CreateBuilder(args); // 建構器
builder.Services.AddSignalR(); // 註冊 SignalR 服務
var app = builder.Build(); // 建構應用程式
app.MapHub<ChatHub>("/chatHub"); // 將 Hub 對應到 /chatHub 路徑
app.Run(); // 啟動
限流(Rate Limiting)與防護
💡 比喻:夜店的門口管制
- 限流就像夜店門口的保鏢:
- 「一次只能進 100 人」(並發限制)
- 「每個人每小時只能進出 3 次」(速率限制)
- 「VIP 不受限制」(白名單)
- 目的是防止惡意攻擊者(DDoS)或是爬蟲把你的服務搞垮
ASP.NET Core 內建限流
// Program.cs 設定限流
using System.Threading.RateLimiting; // 限流命名空間
var builder = WebApplication.CreateBuilder(args); // 建構器
builder.Services.AddRateLimiter(options => // 新增限流服務
{
// 全域固定視窗限流
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>( // 全域限流器
context => RateLimitPartition.GetFixedWindowLimiter( // 固定視窗演算法
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", // 用 IP 分區
factory: _ => new FixedWindowRateLimiterOptions // 限流選項
{
PermitLimit = 100, // 每個視窗允許 100 個請求
Window = TimeSpan.FromMinutes(1), // 視窗大小:1 分鐘
QueueLimit = 10, // 排隊等待的請求數
QueueProcessingOrder = QueueProcessingOrder.OldestFirst // 先進先出
}
)
);
// 針對特定端點的限流策略
options.AddFixedWindowLimiter("ApiPolicy", opt => // 建立具名策略
{
opt.PermitLimit = 30; // 每個視窗允許 30 個請求
opt.Window = TimeSpan.FromMinutes(1); // 視窗:1 分鐘
opt.QueueLimit = 5; // 排隊數量
});
// 被限流時的回應
options.OnRejected = async (context, token) => // 被拒絕時的處理
{
context.HttpContext.Response.StatusCode = 429; // 回傳 429 Too Many Requests
await context.HttpContext.Response.WriteAsync( // 寫入回應內容
"請求太頻繁,請稍後再試。", cancellationToken: token // 中文錯誤訊息
);
};
});
var app = builder.Build(); // 建構應用程式
app.UseRateLimiter(); // 啟用限流中介軟體
// 在 Controller 上套用特定策略
app.MapGet("/api/data", () => "OK") // API 端點
.RequireRateLimiting("ApiPolicy"); // 套用 ApiPolicy 限流策略
SignalR Scale-Out(Redis Backplane)
💡 比喻:連鎖餐廳的廣播系統 如果你的聊天室跑在多台伺服器上:
- 使用者 A 連到 Server 1,使用者 B 連到 Server 2
- A 發訊息給 B,但 Server 1 不知道 B 在 Server 2 上
- Redis Backplane 就像連鎖餐廳的內部廣播系統
- Server 1 把訊息發到 Redis,Redis 廣播給所有 Server
- Server 2 收到後轉發給使用者 B
設定 Redis Backplane
// 安裝 NuGet 套件
// dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis // 安裝 Redis 套件
// Program.cs 設定 SignalR + Redis
var builder = WebApplication.CreateBuilder(args); // 建構器
builder.Services.AddSignalR() // 註冊 SignalR
.AddStackExchangeRedis( // 加入 Redis Backplane
"localhost:6379", // Redis 連線位址
options => // Redis 選項
{
options.Configuration.ChannelPrefix = // 頻道前綴
RedisChannel.Literal("MyApp"); // 避免不同應用衝突
}
);
var app = builder.Build(); // 建構應用程式
app.MapHub<ChatHub>("/chatHub"); // 對應 Hub 路徑
app.Run(); // 啟動
// Redis Backplane 運作流程
使用者 A ──→ Server 1 ──┐
├──→ Redis(訊息中繼站)──→ 廣播給所有 Server
使用者 B ──→ Server 2 ──┘ │
├──→ Server 1 ──→ 使用者 A(收到)
└──→ Server 2 ──→ 使用者 B(收到)
# 安裝 Redis(Ubuntu)
sudo apt update # 更新套件清單
sudo apt install redis-server -y # 安裝 Redis
# 設定 Redis
sudo nano /etc/redis/redis.conf # 編輯設定檔
# 將 bind 127.0.0.1 改為 bind 0.0.0.0 # 如果需要外部存取
# 設定 requirepass YourRedisPassword # 設定密碼保護
sudo systemctl restart redis # 重啟 Redis 套用設定
sudo systemctl enable redis # 設定開機自動啟動
# 測試 Redis 連線
redis-cli ping # 應該回傳 PONG
redis-cli info clients # 查看連線數量
🤔 我這樣寫為什麼會錯?
❌ 錯誤一:在非同步方法中使用同步呼叫
// 錯誤:在 async 方法裡使用 .Result 或 .Wait()
public async Task<IActionResult> GetData() // 非同步方法
{
var result = _httpClient.GetStringAsync("https://api.example.com").Result; // 用 .Result 同步等待!
return Ok(result);
}
// 問題:.Result 會阻塞執行緒,可能造成死結(Deadlock)
// 在 ASP.NET Core 中,這會導致應用程式凍結
// ✅ 正確做法:全程使用 await
public async Task<IActionResult> GetData() // 非同步方法
{
var result = await _httpClient.GetStringAsync("https://api.example.com"); // 用 await 非同步等待
return Ok(result); // 不會阻塞執行緒
}
❌ 錯誤二:每次請求都 new HttpClient
// 錯誤:在 Controller 中直接 new HttpClient
public class MyController : Controller
{
public async Task<IActionResult> CallApi()
{
using var client = new HttpClient(); // 每次都建立新的 HttpClient!
var result = await client.GetStringAsync("https://api.example.com");
return Ok(result);
}
}
// 問題:HttpClient 的 Dispose 不會立即釋放 Socket
// 大量請求會導致 Socket Exhaustion 錯誤
// ✅ 正確做法:使用 IHttpClientFactory
public class MyController : Controller
{
private readonly IHttpClientFactory _factory; // 注入工廠
public MyController(IHttpClientFactory factory) // 建構函式注入
{
_factory = factory; // 儲存參考
}
public async Task<IActionResult> CallApi()
{
var client = _factory.CreateClient(); // 從工廠取得(連線會被重用)
var result = await client.GetStringAsync("https://api.example.com");
return Ok(result);
}
}
❌ 錯誤三:JWT 金鑰太短或硬編碼
// 錯誤:使用太短的金鑰
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("123") // 金鑰太短!至少要 256 位元(32 字元)
);
// 錯誤:金鑰直接寫在程式碼中
var secretKey = "MySecretKeyHardcodedInSourceCode123!"; // 程式碼可能被推到 GitHub!
// ✅ 正確做法:從環境變數或 User Secrets 讀取
var secretKey = builder.Configuration["Jwt:SecretKey"]; // 從設定檔讀取
// 金鑰長度至少 32 字元以上
// 生產環境存在環境變數中,不要提交到版本控制