程式碼
https://github.com/intervalrain/webapi_ca/
正文
建置 Solution
首先先參考 Clean Architecture 最經典的同心圓,來確定我們需要將我們的解決方案做哪些分層:
- 我將使用 Restful API 做為我們 I/O (Presentation Layer)
- 並且我需要配備身份驗證的機制 (Presentation Layer)
- 我使用 PostgresDB 作為我的 (Infrastructure Layer)
- 我的核心商業邏輯 (Application / Domain Layer)
創建專案
dotnet new sln -o Mysln
- 進入專案所在的資料夾
cd Mysln
- 根據預先的分層建立專案資料夾,並且使用 dotnet 指令建立相對應的專案類型。
- Api –> WebAPI
- Infrastructure –> classlib
- Contracts –> classlib
- Application –> classlib
- Domain –> classlib
dotnet new webapi -o Mysln.Api
dotnet new classlib -o Mysln.Contracts
dotnet new classlib -o Mysln.Infrastructure
dotnet new classlib -o Mysln.Application
dotnet new classlib -o Mysln.Domain
- 接著我們需要把產生的專案資料夾,加入到我們的 Solution。
dotnet sln add Mysln.Api
dotnet sln add Mysln.Application
dotnet sln add Mysln.Contracts
dotnet sln add Mysln.Domain
dotnet sln add Mysln.Infrastructure
- 接下來按照 Clean Architecture 的依賴原則來設定 dependency,依我的專案來說依賴方向如下。graph TD; Api-->Contracts; Api-->Application; Infrastructure-->Application Application-->Domain Api-.->Infrastructure
dotnet add Mysln.Api reference Mysln.Contracts Mysln.Application
dotnet add Mysln.Infrastructure reference Mysln.Application
dotnet add Mysln.Application reference Mysln.Domain
dotnet add Mysln.Api reference Mysln.Infrastructure
- 至此,已經完成了基本的 hierarchy 建置,接下來要為 Restful Client 做準備。
Login Authentication
- 作為驗證的需要,我們需要以下三種驗證檔案,包含兩個 Request 與一個 Response
public record RegisterRequest(
string FirstName,
string LastName,
string Email,
string Password
);
public record LoginRequest(
string Email,
string Password
);
public record AuthenticationResponse(
Guid Id,
string FirstName,
string LastName,
string Email,
string token
);
- 到 Controller 去設置註冊與登入的兩個路由,並且將之後的服務介面預先注入到其中。
[ApiController]
[Route("auth")]
public class AuthenticationController : ControllerBase
{
[HttpPost("register")]
public IActionResult Register(RegisterRequest request)
{
return Ok(request);
}
[HttpPost("login")]
public IActionResult Login(LoginRequest request)
{
return Ok(request);
}
}
- 接著我們創建 Application 中的服務,注意到因為 Application 不依賴於 Contracts,故我們這邊需要創建自己的 DataModel
public record AuthenticationResult
(
Guid Id,
string FirstName,
string LastName,
string Email,
string Token
);
- 接著我們定義出 Application 的 Service。
public interface IAuthenticationService
{
AuthenticationResult Register(string firstName, string lastName, string email, string password);
AuthenticationResult Login(string email, string password);
}
- 定義好我們的 service interface 之後,就可以到 Presentation 中將我們的 service 注入到 presentation 之中。
[ApiController]
[Route("auth")]
public class AuthenticationController : ControllerBase
{
private readonly IAuthenticationService _authenticationService;
public AuthenticationController(IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
[HttpPost("register")]
public IActionResult Register(RegisterRequest request)
{
var authResult = _authenticationService.Register(
request.FirstName,
request.LastName,
request.Email,
request.Password);
var response = new AuthenticationResponse(
authResult.Id,
authResult.FirstName,
authResult.LastName,
authResult.Email,
authResult.Token);
return Ok(response);
}
[HttpPost("login")]
public IActionResult Login(LoginRequest request)
{
var authResult = _authenticationService.Login(
request.Email,
request.Password);
var response = new AuthenticationResponse(
authResult.Id,
authResult.FirstName,
authResult.LastName,
authResult.Email,
authResult.Token);
return Ok(response);
}
}
- 我們已經定義好我們的 service 後,便可以到 presentation 的 Program(或是其它入口點,如 Startup.cs 或 MauiProgram.cs),做 service 的依賴注入。
using BuberDinner.Application.Services.Authentication;
var builder = WebApplication.CreateBuilder(args);
{
builder.Services.AddScoped<IAuthenticationService, AuthenticationService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
}
var app = builder.Build();
{
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
}
- 最後,我們先實作一個暫時的 Service,來確認 Api 是可以作業的。
public class AuthenticationService : IAuthenticationService
{
public AuthenticationResult Register(string firstName, string lastName, string email, string password)
{
return new AuthenticationResult(
Guid.NewGuid(),
firstName,
lastName,
email,
"token"
);
}
public AuthenticationResult Login(string email, string password)
{
return new AuthenticationResult(
Guid.NewGuid(),
"firstName",
"lastName",
email,
"token"
);
}
}
- 執行
dotnet run --project .\Mysln.Api\
- 在 Swagger 中測試我們實作的
register
與login
API,如果正常工作,會回傳 StatusCode: 200。
Dependency Injection
- 我們想要每一層都可以自己管理自己的注入,此時我們需要引入
Microsoft.Extensions.DependencyInjection
。 - 接下來實作 Application 的 DependencyInjection。
public static class DependencyInjection
{
public static IServiceCollection AddApllication(this IServiceCollection services)
{
services.AddScope<IAuthenticationService, AuthenticationService>();
return services;
}
}
- 接下來實作 Infrastructure 的 DependencyInjection。(暫時還沒有注入 repository)
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
// 未來要注入 repositories
return services;
}
}
- 接下來我們可以改寫
Program.cs
。
using BuberDinner.Application;
using BuberDinner.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
{
builder.Services
.AddApplication()
.AddInfrastructure();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
}
var app = builder.Build();
{
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
}
實作 JWT Token Generator
- 首先先在 Application Layer 創建一個 interface 來做依賴反轉
public interface IJwtTokenGenerator
{
string GenerateToken(Guid userId, string firstName, string lastName);
}
- 接著我們到 Infrastructure Layer 來實作我們的 JwtTokenGenerator。
- 首先我們需要
System.IdentityModel.Tokens.Jwt
這個 Package。 - 接著我們實作
JwtTokenGenerator
。
public class JwtTokenGenerator : IJwtTokenGenerator
{
public string GenerateToken(Guid userId, string firstName, string lastName)
{
var signingCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("super-secret-key")),
SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.GivenName, firstName),
new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var securityToken = new JwtSecurityToken(
issuer: "Mysln",
expires: DateTime.Now.AddDays(1),
claims: claims,
signingCredentials: signingCredentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
}
- 接著我們將之注入到服務中,即大功告成了。
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
return services;
}
}
使用 Options Pattern 注入 JWT Settings
- 接下來我們要使用 Options Pattern 將 JWT Settings 注入到 JwtTokenGenerator 中。
- 首先我們先到
Mysln.Api
的appsettings.json
中將 options 設置完成。
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"JwtSettings": {
"Secret": "super-secret-key",
"ExpiryMinutes": 60,
"Issuer": "Mysln",
"Audience:": "Mysln"
}
}
- 由於我們要使用 Options Pattern,我們需要改寫我們的
Program.cs
,並且將 ConfigurationManager 注入到 Infrastructure 的 DependencyInjection。 - 為此我們需要引入套件
Microsoft.Extensions.Configuration
與Microsoft.Extensions.Options.ConfigurationExtensions
。 - 並且我們需要創建一個 Model。
public class JwtSettings
{
public const string SectionName = "JwtSettings";
public string Secret { get; init; } = null!;
public int ExpiryMinutes { get; init; }
public string Issuer { get; init; } = null!;
public string Audience { get; init; } = null!;
}
Program.cs
需改寫成:
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration);
- 將
DependencyInjection
改寫成:
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, ConfigurationManager configuration)
{
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
return services;
}
}
- 接下來,我們可以把 JwtTokenGenerator 改寫成:
public class JwtTokenGenerator : IJwtTokenGenerator
{
private readonly JwtSettings _jwtSettings;
private readonly IDateTimeProvider _dateTimeProvider;
public JwtTokenGenerator(IDateTimeProvider dateTimeProvider, IOptions<JwtSettings> jwtOptions)
{
_dateTimeProvider = dateTimeProvider;
_jwtSettings = jwtOptions.Value;
}
public string GenerateToken(Guid userId, string firstName, string lastName)
{
var signingCredentials = new SigningCredentials(
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_jwtSettings.Secret)),
SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.GivenName, firstName),
new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var securityToken = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
expires: _dateTimeProvider.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes),
claims: claims,
signingCredentials: signingCredentials);
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
}
- 以上就大功告成了。
使用 dotnet user-secrets
指令
- 如果不想要將 Options 中的 secret 儲存在程式(appsettings.json)裡面,可以利用
dotnet user-secrets
將 secret 儲存於環境變數裡面。 - 透過執行以下的指令來初始化專案的
UserSecretsId
dotnet user-secrets init --project Mysln.Api
- 接著將
UserSecretsId
綁定到我們專案的JwtSettings:Secret
。
dotnet user-secrets set --project Mysln.Api "JwtSettings:Secret"
- 日後可以經由以下指令查詢。
dotnet user-secrets list --project Mysln.Api
Domain Model
- 先建立一個簡單的 Domain Model(Entity)
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string Email { get; set; } = null!;
public string Password { get; set; } = null!;
}
Repository Pattern
- 在 Application Layer 建立 IRepository
public interface IUserRepository
{
User? GetUserByEmail(string email);
void Add(User user);
}
- 將 IRepository 注入 Application 的 Service
- 並用查改存推改寫 Service
public class AuthenticationService : IAuthenticationService
{
private readonly IJwtTokenGenerator _jwtTokenGenerator;
private readonly IUserRepository _userRepository;
public AuthenticationService(IJwtTokenGenerator jwtTokenGenerator, IUserRepository userRepository)
{
_jwtTokenGenerator = jwtTokenGenerator;
_userRepository = userRepository;
}
public AuthenticationResult Register(string firstName, string lastName, string email, string password)
{
// 查
if (_userRepository.GetUserByEmail(email) is not null)
{
throw new Exception("User with given email already exists.");
}
// 改
var user = new User
{
FirstName = firstName,
LastName = lastName,
Email = email,
Password = password
};
// 存
_userRepository.Add(user);
// 推
var token = _jwtTokenGenerator.GenerateToken(user.Id, firstName, lastName);
return new AuthenticationResult(user.Id, firstName, lastName, email, token);
}
public AuthenticationResult Login(string email, string password)
{
// 查
if (_userRepository.GetUserByEmail(email) is not User user)
{
throw new Exception("User with given email does not exist.");
}
if (user.Password != password)
{
throw new Exception("Invalid password.");
}
// 改
var token = _jwtTokenGenerator.GenerateToken(user.Id, user.FirstName, user.LastName);
return new AuthenticationResult(user.Id, user.FirstName, user.LastName, email, token);
}
}
- 接著我們在 Infrastructure Layer 實作我們的 repository,我們暫時先不接資料庫,所以先做一個 InMemory 版本的 repository 來做測試。
public class UserRepository : IUserRepository
{
private readonly List<User> _users = new();
public void Add(User user)
{
_users.Add(user);
}
public User? GetUserByEmail(string email)
{
return _users.SingleOrDefault(u => u.Email.Equals(email));
}
}
- 實作完需要透過 DependencyInjection 注入到我們的 Service Container 內。
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, ConfigurationManager configuration)
{
services.Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName));
services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
services.AddSingleton<IUserRepository, UserRepository>();
return services;
}
}
- 至此,我們已經完成了一個簡單的身份認證的 API。