实战项目-苍穹外卖 这是一个基于ASP.NET Core WebAPI框架(.NET6.0)重置的苍穹外卖
我会在这里记录学习过程中的任何问题和笔记
项目
目录名
作用
Storage
用于存放实体类和DbContext,也可以叫做EFCore
Dal
根据业务进一步调用DbContext,封装成具体业务方法,像是仓储模式
Model
存放Vo,Dto,常量和枚举类型,自定义特性
Bll
业务层代码
Commen
存放工具类
EFCore 在EFCore的DbContext做实体类和表的映射时,我们通常用实体类加s来作为属性名
这是一种约定,通常用来表示这是一组实体,一个集合
在Code First开发模式中,迁移插件会自动将这个加上s的属性作为表名来迁移(可以手动配置)
isAsNoTracking 在对Dao层方法创建时,我们可以提供一个形参isAsNoTracking = true来决定是否进行实体跟踪
如果该Dao层方法仅用来查询 ,我们可以禁用实体跟踪,可以减少内存使用并提高查询性能
注:虽然EFCore不进行实体跟踪,但其数据库查询到的主键仍然会回显
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public xiaobai_cangqiong_Storage.Entity.Employee? GetByUsername(string username) { return _dbContext.Employees.FirstOrDefault(e => e.Username == username); }public xiaobai_cangqiong_Storage.Entity.Employee? GetByUsername(string username, bool isAsNoTracking = true ) { var data = _dbContext.Employees.Where(e => e.Username == username); if (isAsNoTracking) { return data.AsNoTracking().FirstOrDefault(); } return data.AsNoTracking().FirstOrDefault(); }
SaveChange 在Java中,我们通过添加过滤器的方式来实现对公共字段的填充
在DBContext中,我们可以通过重写SaveChanges方法的方式,在每次调用前进行一些操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public override int SaveChanges () { OnBeforeSaving(); return base .SaveChanges(); }public override Task<int > SaveChangesAsync (bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default ) { OnBeforeSaving(); return base .SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); }private void OnBeforeSaving () { }
IAuditable接口
类型安全:通过实现 IAuditable 接口,你可以确保所有需要自动填充的实体类都具有相同的属性结构。这有助于避免在运行时出现类型不匹配的问题。
代码复用:接口提供了一种标准化的方式来定义一组公共属性,使得你在处理这些实体时可以使用统一的逻辑。
易于扩展:如果你将来需要添加更多的审计字段或修改现有的字段,只需要修改接口即可,而不需要逐一修改每个实体类。
1 2 3 4 5 6 7 public interface IAuditable { DateTime CreateTime { get ; set ; } DateTime UpdateTime { get ; set ; } long CreateUser { get ; set ; } long UpdateUser { get ; set ; } }
让具有公共字段的实体类实现这个接口
OnBeforeSaving 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 private void OnBeforeSaving () { foreach (var entry in ChangeTracker.Entries<IAuditable>()) { if (entry.State == EntityState.Added) { entry.Entity.CreateTime = DateTime.Now; entry.Entity.UpdateTime = DateTime.Now; entry.Entity.CreateUser = long .Parse(_httpContextAccessor.HttpContext?.Items["UserId" ]?.ToString() ?? string .Empty); entry.Entity.UpdateUser = long .Parse(_httpContextAccessor.HttpContext?.Items["UserId" ]?.ToString() ?? string .Empty); } else if (entry.State == EntityState.Modified) { entry.Entity.UpdateTime = DateTime.Now; entry.Entity.UpdateUser = long .Parse(_httpContextAccessor.HttpContext?.Items["UserId" ]?.ToString() ?? string .Empty); } } }
AutoMapper 在调用AutoMapper时,有两种方式
将employee实体类映射到EmployeeLoginResp实体类
1 2 3 4 5 6 var employeeLoginResp = _mapper.Map<EmployeeLoginResp>(employee);var employeeLoginResp = new EmployeeLoginResp(); _mapper.Map(employee, employeeLoginResp);
奇淫巧计这里非常感谢雨哥和翔哥给出纠正,再正式项目中,表中一定会存在特别为其置空的字段,那么对与这种方法是完全错误的思路,还是要针对于某一个业务制定特定的接口才行
我们将update更新方法通过if判断的方式写成动态的update,可以满足所有业务的更新需求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public void Update (xiaobai_cangqiong_Storage.Entity.Category category ) { var single = _dbContext.Categories.Single(c => c.Id == category.Id); if (category.Type != null ) { single.Type = category.Type; } if (category.Name != null ) { single.Name = category.Name; } if (category.Sort != null ) { single.Sort = category.Sort; } if (category.Status != null ) { single.Status = category.Status; } if (category.UpdateTime != null ) { single.UpdateTime = category.UpdateTime; } if (category.UpdateUser != null ) { single.UpdateUser = category.UpdateUser; } _dbContext.SaveChanges(); }
但这样写,当实体类属性(表中的列)过多时,我们需要动态判断的属性太多了,太过于麻烦
我们可以通过AutoMapper映射自己,做非空字段的映射
1 2 3 4 CreateMap<Category, Category>() .ForAllMembers(opt => opt.Condition((src, dest, srcMember) => srcMember != null ));
ForAllMembers 方法用于为所有成员(属性)应用相同的条件。
opt.Condition 方法定义一个条件,只有当条件满足时才进行属性映射。
src:源对象(即要映射的对象)。
dest:目标对象(即被映射的对象)。
srcMember:源对象的成员(属性)。
srcMember != null:只有当源对象的属性值不为 null 时,才进行映射。
需要注意的是,自我映射的非空判断仅仅会判断引用类型的数据是否为空,但在我们数据表中,很多字段为基本数据类型,默认为0
对于想要进行自我映射实现动态更新,必须保证实体类中基本类型属性,例如:int,long……这一类型数据必须为int?,long?
因为如果为基本数据类型,自我映射时进行非空判断会出现问题,基本数据类型的默认为0,但如果是int?数据类型,默认为null
JWT 我们选择使用JWT来签发Token进行项目的身份验证
依赖导入
Microsoft.AspNetCore.Authentication.JwtBearer
配置文件 1 2 3 4 5 "Jwt" : { "Key" : "<your_secret_key>" , "Issuer" : "<your_issuer>" , "Audience" : "<your_audience>" }
注册服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 #region 注册JWT服务 builder.Services.AddScoped<JwtUtil>(); builder.Services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(x => { x.RequireHttpsMetadata = false ; x.SaveToken = true ; x.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true , IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(builder.Configuration["Jwt:Key" ] ?? throw new InvalidOperationException())), ValidateIssuer = true , ValidIssuer = builder.Configuration["Jwt:Issuer" ], ValidateAudience = true , ValidAudience = builder.Configuration["Jwt:Audience" ] }; x.EventsType = typeof (CustomJwtBearerEvents); });#endregion
注册中间件 1 2 3 app.UseAuthentication(); app.UseAuthorization();
JWTUtil工具类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 public class JwtUtil { private readonly string _jwtKey; private readonly string _jwtIssuer; private readonly string _jwtAudience; public JwtUtil (IConfiguration configuration ) { _jwtKey = configuration["Jwt:Key" ] ?? throw new InvalidOperationException(); _jwtIssuer = configuration["Jwt:Issuer" ] ?? throw new InvalidOperationException(); _jwtAudience = configuration["Jwt:Audience" ] ?? throw new InvalidOperationException(); } public string GenerateToken (string userId, TimeSpan expirationTime ) { var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.ASCII.GetBytes(_jwtKey); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, userId) }), Expires = DateTime.UtcNow.Add(expirationTime), Issuer = _jwtIssuer, Audience = _jwtAudience, SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } }
注意事项 JWT签发的token为键值对Token,而JWT通过特性来验证Token时默认采用以下格式:
Authorization: Bearer <token>
但苍穹外面的前端项目的token负载采用的是以下格式:
Token: <token>
CustomJwtBearerEvents 自定义JWT处理业务方式与逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class CustomJwtBearerEvents : JwtBearerEvents { public override Task MessageReceived (MessageReceivedContext context ) { var token = context.Request.Headers["Token" ].FirstOrDefault(); if (!string .IsNullOrEmpty(token)) { context.Token = token; } return base .MessageReceived(context); } }
MD5工具类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public static class MD5Util { public static string GetMd5Hash (string input ) { using (MD5 md5 = MD5.Create()) { byte [] inputBytes = Encoding.UTF8.GetBytes(input); byte [] hashBytes = md5.ComputeHash(inputBytes); StringBuilder sb = new StringBuilder(); for (int i = 0 ; i < hashBytes.Length; i++) { sb.Append(hashBytes[i].ToString("x2" )); } return sb.ToString(); } } }
Swagger AddSwaggerGen 1 2 3 4 5 6 7 8 9 10 11 builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1" ,new OpenApiInfo() { Version = "v1" , Title = "xiaobai-cangqiong API" , }); } );
这里如果不创建Swagger文档,系统也会默认帮我们创建一个文档
AddSecurityDefinition与AddSecurityRequirement 在Swagger中添加一个可以携带token请求头的按钮,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #region MyRegion Swagger builder.Services.AddSwaggerGen( options => { options.AddSecurityDefinition("Token" , new OpenApiSecurityScheme { Description = "Token: <token>" , Name = "Token" , In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Token" }, }, new List<string >() } }); });#endregion
HttpContext.Items ASP.NETCore提供了非常好用的方式来获取到用户信息,而不是通过丑陋的过滤器手动将用户信息保存来实现
在Java中,我们通过ThreadLocal来存储过滤器从Token中解析出来的UserId
在ASP.NETCore中,HttpContext.Items可以解决中间件、过滤器、控制器和其他组件在处理同一个请求时共享数据,而无需通过参数传递
TokenValidationFilter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public class TokenValidationFilter : IAsyncActionFilter { private readonly JwtUtil _jwtUtil; public TokenValidationFilter (JwtUtil jwtUtil ) { _jwtUtil = jwtUtil; } public async Task OnActionExecutionAsync (ActionExecutingContext context, ActionExecutionDelegate next ) { var token = context.HttpContext.Request.Headers["Token" ].ToString(); if (!string .IsNullOrEmpty(token)) { try { var userId = _jwtUtil.ParseToken(token); context.HttpContext.Items["UserId" ] = userId; } catch (System.Exception ex) { context.Result = new UnauthorizedResult(); return ; } } else { context.Result = new UnauthorizedResult(); return ; } await next(); } }
注册filter 在需要使用UserId的请求的控制器中加入[TypeFilter(typeof(TokenValidationFilter))]特性
一般为增加和修改的业务上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 [HttpPost ] [TypeFilter(typeof(TokenValidationFilter)) ]public Result<string > Save ([FromBody] EmployeeSaveReq employeeSaveReq ) { _logger.LogInformation("新增员工:{}" , employeeSaveReq); _employeeService.Save(employeeSaveReq); return Result<string >.Success(); }
IHttpContextAccessor 在使用HttpContext.Items时,我们要注册服务之后才能在控制器/服务层中调用,获取到存储在Items中的值
注册服务
builder.Services.AddHttpContextAccessor();
1 2 3 4 5 6 7 8 9 10 private readonly IHttpContextAccessor _httpContextAccessor;public UserService (IHttpContextAccessor httpContextAccessor ) { _httpContextAccessor = httpContextAccessor; } employee.CreateUser = long .Parse(_httpContextAccessor.HttpContext?.Items["UserId" ]?.ToString() ?? string .Empty); employee.UpdateUser = long .Parse(_httpContextAccessor.HttpContext?.Items["UserId" ]?.ToString() ?? string .Empty);
配合OnBeforeSaving实现每次存储前自动填充字段
注意事项 默认情况下,类库项目只会引用.NET框架而不会引用ASP.NETCore框架,所以会存在在别的层无法注入依赖的情况
比如_httpContextAccessor.HttpContext对象无法注入在Bll的Service中
我们可以通过项目文件来为项目添加ASP.NETCore框架
1 2 3 <ItemGroup > <FrameworkReference Include ="Microsoft.AspNetCore.App" /> </ItemGroup >
OSS
依赖安装
Aliyun.OSS.SDK.NetCore
注:依赖导入Aliyun.OSS.SDK可能会出现意料之外的问题
配置文件 1 2 3 4 5 6 "OSS" : { "Endpoint" : "oss-cn-qingdao.aliyuncs.com" , "AccessKeyId" : "your_access_key_id" , "AccessKeySecret" : "your_access_key_secret" , "BucketName" : "your_bucket_name" }
工具类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class AliOssUtil { private readonly string _endpoint; private readonly string _accessKeyId; private readonly string _accessKeySecret; private readonly string _bucketName; public AliOssUtil (IConfiguration configuration ) { _endpoint = configuration["OSS:Endpoint" ] ?? throw new InvalidOperationException(); _accessKeyId = configuration["OSS:AccessKeyId" ] ?? throw new InvalidOperationException(); _accessKeySecret = configuration["OSS:AccessKeySecret" ] ?? throw new InvalidOperationException(); _bucketName = configuration["OSS:BucketName" ] ?? throw new InvalidOperationException(); } public string Upload (Stream fileStream, string fileName ) { var ossClient = new OssClient(_endpoint, _accessKeyId, _accessKeySecret); ossClient.PutObject(_bucketName, fileName, fileStream); return $"https://{_bucketName} .{_endpoint} /{fileName} " ; } }
注册服务 1 services.AddSingleton<AliOssUtil>();
阿里云oss注册为单例服务即可
使用服务 1 2 3 4 5 6 7 [HttpPost("upload" ) ]public Result<string > Upload ([FromForm]IFormFile file ) { _logger.LogInformation("文件上传:{}" , file ); using var stream = file .OpenReadStream(); return Result<string >.Success(_aliOssUtil.Upload(stream, file .FileName)); }
Settings 我们在Jwt和Oss一类的工具类中,直接使用了IConfiguration注入的方式来注入配置参数
1 2 3 4 5 6 7 8 9 10 11 12 private readonly string _endpoint;private readonly string _accessKeyId;private readonly string _accessKeySecret;private readonly string _bucketName;public AliOssUtil (IConfiguration configuration ) { _endpoint = configuration["OSS:Endpoint" ] ?? throw new InvalidOperationException(); _accessKeyId = configuration["OSS:AccessKeyId" ] ?? throw new InvalidOperationException(); _accessKeySecret = configuration["OSS:AccessKeySecret" ] ?? throw new InvalidOperationException(); _bucketName = configuration["OSS:BucketName" ] ?? throw new InvalidOperationException(); }
我们可以创建Settings配置类,来简化配置注入,将服务注入为强类型
配置类 1 2 3 4 5 6 7 public class OssSettings { public string Endpoint { get ; set ; } = null !; public string AccessKeyId { get ; set ; } = null !; public string SecretAccessKey { get ; set ; } = null !; public string BucketName { get ; set ; } = null !; }
注册服务 1 2 builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt" )); builder.Services.Configure<OssSettings>(builder.Configuration.GetSection("OSS" ));
注入服务 1 2 3 4 5 6 private readonly OssSettings _ossSettings;public AliOssUtil (IOptions<OssSettings> ossSettings ) { _ossSettings = ossSettings.Value; }
事务 在此项目中,我们使用的是DBFirst开发模式,并且用的是逻辑外键的开发形式,不存在数据库层级的物理外键,也不存在级联关系的插入和查询
所以我们要在Service层完成对两个表的分别操作(增删改),我们就需要在Service层加入事务,来保证操作的原子性
在原Java项目中,我们可以通过Spring框架提供的Transactional注解来自动实现整个方法的事务,在ASP.NETCore中,没有类似这个注解的特性
所以我们要通过TransactionScope对象来实现事务
1 2 3 4 using (var scope = new TransactionScope()) { scope.Complete(); }
小贴士 对于List<T>, ICollection<T>, IReadOnlyCollection<T> 等实现了 IEnumerable<T> 接口的集合
1 2 3 dishFlavors!=null && dishFlavors.Any(); dishFlavors!=null && dishFlavors.Length>0 ;
使用Any方法替换Length方法对集合做非空判断,性能更好,但数组的情况下无法使用Any方法,仍使用Length做非空判断
EFCore的事务实现 EFCore的SaveChanges 会自动实现事务,也可以使用DbContext.Database.BeginTransaction() 方法开始一个事务,并且使用 Commit() 或 Rollback() 来提交或回滚事务
优点:避免了TransactionScope事务的性能问题、连接池问题、外部依赖问题
缺点:需要在service层注入DbContext对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 using (var transaction = await _dbContext.Database.BeginTransactionAsync()) { try { await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw ; } }
Redis
注入依赖
Microsoft.Extensions.Caching.StackExchangeRedis
注册服务 1 2 3 4 5 builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration["Redis:Configuration" ]; options.InstanceName = builder.Configuration["Redis:InstanceName" ]; });
注入服务 1 2 3 4 5 6 7 private readonly IDistributedCache _distributedCache;public ShopController (ILogger<ShopController> logger, IDistributedCache distributedCache ) { _distributedCache = distributedCache; }
Get&Set _distributedCache(分布式缓存)服务常用的两个方法为Get(GetAsync)和Set(SetAsync),使用起来简单灵活
但需要注意,这两个方法的参数为字节数组,而通常我们是需要将其他数据类型存入Redis,这就涉及到了数据类型转换 的问题
简单数据类型 对于简单的数据类型(如整数、布尔值等),可以直接使用 BitConverter.GetBytes 转换为 byte[]
在反序列化时,调用BitConverter.ToInt32
字符串类型 通过System.Text.Encoding将string转换为byte[]
1 2 3 4 5 Encoding.UTF8.GetBytes(str); Encoding.UTF8.GetString(bytes);
复杂类型 对于复杂类型的对象,我们通常先调用JsonSerializer.Serialize()将其序列化成字符串,然后再将字符串序列化为byte[]
1 2 3 4 5 6 7 byte [] bytes = Encoding.UTF8.GetBytes(jsonString);await _distributedCache.SetAsync("SHOP_STATUS" , bytes, new DistributedCacheEntryOptions());string jsonString = Encoding.UTF8.GetString(cachedBytes);var deserializedStatusInfo = JsonSerializer.Deserialize<YourType>(jsonString);
SetString&GetString 在.NET6之后的版本中,_distributedCache为我们额外提供了SetString(SetStringAsync)和GetString(GetStringAsync)
简化了我们序列化和反序列化的操作,至此,我们可以将所有想要存入redis的对象都序列化为字符串直接存入
注意事项 在这个项目中,我们为用户端 的菜品及套餐的查询操作应用了缓存,在管理端 的菜品增删改中实现了清空缓存达到及时更新数据
我们使用工具类CacheUtils将增删查的操作封装,简化了代码
控制器命名 在spring中,如果同时存在user包的ShopController和Admin包的ShopController,在没有指定bean名称的时候,会抛出异常,因为这两个Controller都会被注入到IoC容器中,名称会冲突,但为什么ASP.NETCore中就可以存在两个相同名称的Controller呢?
在 ASP.NET Core 中,MVC 和 Razor Pages 的默认行为是基于约定来发现控制器。MVC 会扫描程序集中的所有类型,并根据命名规则(例如类名以 “Controller” 结尾)来识别控制器。当找到多个具有相同名称的控制器时,ASP.NET Core 不会直接抛出异常,而是通过路由系统 来区分这些控制器。
路由配置 :每个控制器通常有自己的路由前缀,即使两个控制器有相同的名称,它们的完整路径(包括命名空间)通常是不同的
命名空间 :即使没有显式的路由配置,ASP.NET Core 也会使用控制器的命名空间作为默认的路由前缀。这意味着如果两个控制器位于不同的命名空间中,它们的默认路由也将是不同的。
这两种框架的不同设计哲学和实现方式导致了在处理同名控制器或 Bean 时的行为差异。ASP.NET Core 更加依赖于路由和命名空间来区分控制器,而 Spring 则要求开发者明确指定 Bean 的名称或使用限定符来避免冲突。
HttpClient 在.NETCore中已经内置了HttpClient
注册服务 1 2 3 4 5 6 builder.Services.AddHttpClient("WxLogin" , client => { client.BaseAddress = new Uri("https://api.weixin.qq.com/" ); client.Timeout = TimeSpan.FromSeconds(5 ); });
注入依赖 1 2 3 4 5 6 private readonly HttpClient _httpClient;public UserServiceDao (IOptions<WxSetting> wxSetting, IHttpClientFactory httpClientFactory ) { _httpClient = httpClientFactory.CreateClient("WxClient" ); }
发送请求 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 var uriBuilder = new UriBuilder(_httpClient.BaseAddress); uriBuilder.Query = QueryHelpers.AddQueryString(uriBuilder.Query, new Dictionary<string , string ?> { { "appId" , _wxSetting.AppId }, { "secret" , _wxSetting.AppSecret }, { "js_code" , code }, { "grant_type" , _wxSetting.GrantType } });var response = await _httpClient.GetAsync(uriBuilder.Uri);if (response.IsSuccessStatusCode) { result = await response.Content.ReadAsStringAsync(); }
时间区间 将开始时间和结束时间的日期区间中的每一天加入到集合中
1 2 3 4 5 6 List<DateTime> dateList = new List<DateTime>();while (begin <= end) { dateList.Add(begin); begin = begin.AddDays(1 ); }
集合转换字符串 通过string.Join的工具方法,搭配Linq的select语句可以将集合转换为字符串并指定换行符
1 2 DateList = string .Join("," , dateList.Select(d => d.ToString("yyyy-MM-dd" ))), TurnoverList = string .Join("," , sum.Select(d => d.ToString("0.00" )))