.NET 8.0 - 使用存储库模式和 Dapper 进行日志记录和单元测试的清洁架构
在本文中,我们将了解清洁架构并引导您了解 .NET 8.0 中的示例 CRUD API。
示例代码:https://download.csdn.net/download/hefeng_aspnet/91474246
我们将在此示例中使用以下工具、技术和框架:
• Visual Studio 2022和.NET 8.0
• C#
• MS SQL 数据库
• Clean Architecture
• Dapper(mini ORM)
• Repository Pattern (存储库模式)
• Unit of Work (工作单元)
• Swagger UI
• API Authentication (Key Based) [API 身份验证(基于密钥)]
• Logging (using log4net) [日志记录(使用 log4net)]
• Unit Testing (MSTest Project)[单元测试(MSTest 项目)]
在开始示例应用程序之前,让我们了解清晰的架构及其好处。
软件架构的目标是尽量减少构建和维护所需系统所需的人力资源。——罗伯特·C·马丁,《清洁架构》
清洁架构解释:
清洁架构 (Clean Architecture) 是由罗伯特·C·马丁 (Robert C. Martin)(又名鲍勃大叔)提出的系统架构指南。它衍生自许多架构指南,例如六边形架构、洋葱架构等。
• 清洁架构的主要概念是应用程序的核心逻辑很少改变,因此它将是独立的并被视为核心。
• 使这种架构发挥作用的首要规则是依赖规则。该规则规定,源代码依赖关系只能指向内部,并且内圈中的任何事物都无法知晓外圈中的任何事物。
• 通过将软件分层并遵循依赖规则,您将创建一个本质上可测试的系统,并享受其带来的所有好处。当系统的任何外部组件(例如数据库或 Web 框架)过时时,您可以轻松替换这些过时的元素。
• 在清晰架构中,领域层和应用层仍然处于设计的中心,被称为应用程序的核心。
• 领域层包含企业逻辑,应用层包含业务逻辑。
• 企业逻辑可以在许多相关系统之间共享,但业务逻辑不可共享,因为它是为特定的业务需求而设计的。
• 如果您没有企业而只是编写单个应用程序,那么这些实体就是该应用程序的业务对象。
清洁架构的优点:
• 框架独立——该架构不依赖于某些功能丰富的软件库。这使得您可以将这些框架用作工具。
• UI 独立 - 它与 UI 层松散耦合。因此,您可以在不改变核心业务的情况下更改 UI。
• 独立于数据库 - 您可以将 SQL Server 或 Oracle 替换为 MongoDB、Bigtable、CouchDB 或其他数据库。您的业务规则不受数据库的约束。
• 高度可维护——遵循关注点分离。
• 高度可测试 - 使用这种方法构建的应用程序,尤其是核心域模型及其业务规则,极易测试。
现在我们已经了解了简洁架构。在开始示例 API 之前,让我们简单回顾一下 Dapper。
Dapper 解释道:
• Dapper 是一个简单的对象映射器或微型 ORM,负责数据库和编程语言之间的映射。
• Dapper 由 Stack Overflow 团队创建,旨在解决他们的问题并将其开源。Dapper 在 Stack Overflow 上的使用本身就展现了它的强大功能。
• 它大大减少了数据库访问代码,并专注于完成数据库任务,而不是完全依赖 ORM。
• 它可以与任何数据库集成,例如 SQL Server、Oracle、SQLite、MySQL、PostgreSQL 等。
• 如果DB已经设计好了,那么使用Dapper是一个最佳且高效的选择。
• 性能:与 Entity Framework 相比,Dapper 的数据查询速度更快。这是因为 Dapper 直接使用 RAW SQL,因此时间延迟相对较小。
在本文中,我们将与 Dapper 一起使用存储库模式和工作单元,并向您展示如何按照存储库模式和工作单元在 ASP.NET 8.0 API 中使用 Dapper。
解决方案和项目设置:
首先,创建一个用于执行 CRUD 操作的新表。您可以使用CleanArch.Sql/Scripts代码示例文件夹下共享的脚本。
一旦我们的后端准备就绪,打开 Visual Studio 2022 并创建一个空白解决方案项目,并将其命名为CleanArch。
设置核心层:在解决方案下,创建一个新的类库项目,并将其命名为CleanArch.Core。
• 添加一个新文件夹Entities并添加一个名为 的新实体类Contact。
public class Contact
{
public int? ContactId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string PhoneNumber { get; set; }
}
这里需要注意的是,核心层不应该依赖于任何其他项目或层。这在使用清洁架构时非常重要。
设置应用层:添加另一个类库项目并将其命名为CleanArch.Application。
• 添加一个新文件夹Application,在此文件夹下,我们将定义将在另一层实现的接口。
• 创建一个通用IRepository接口并定义 CRUD 方法。
public interface IRepository where T : class
{
Task<IReadOnlyList> GetAllAsync();
Task GetByIdAsync(long id);
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(long id);
}
• 添加对项目的引用Core,应用程序项目始终仅依赖于该Core项目。
• 之后添加一个联系人特定存储库(IContactRepository),并从中继承IRepository
• 另外,创建一个新的接口并命名它,IUnitOfWork因为我们将在实现中使用工作单元。
// IContactRepository.cs file
public interface IContactRepository : IRepository
{
}
// IUnitOfWork.cs file
public interface IUnitOfWork
{
IContactRepository Contacts { get; }
}
• 由于我们也在实现日志记录,因此添加一个ILogger接口并添加不同日志级别的方法。
///
///
public interface ILogger
{
/* Log a message object */
void Debug(object message);
void Info(object message);
void Warn(object message);
void Error(object message);
void Fatal(object message);
/* Log a message object and exception */
void Debug(object message, Exception exception);
void Info(object message, Exception exception);
void Warn(object message, Exception exception);
void Error(object message, Exception exception);
void Fatal(object message, Exception exception);
/* Log an exception including the stack trace of exception. */
void Error(Exception exception);
void Fatal(Exception exception);
}
设置日志记录:添加新的类库项目(CleanArch.Logging)
• 我们将使用 Log4Net 库进行日志记录,因此log4net从 NuGet 包管理器安装包。
• 添加对项目的引用Application,然后添加新类Logger并实现ILogger接口。
public sealed class Logger : Application.Interfaces.ILogger
{
#region ===[ Private Members ]=============================================================
private static readonly ILog _logger = LogManager.GetLogger(MethodBase.GetCurrentMethod()?.DeclaringType);
private static readonly Lazy _loggerInstance = new Lazy(() => new Logger());
private const string ExceptionName = \"Exception\";
private const string InnerExceptionName = \"Inner Exception\";
private const string ExceptionMessageWithoutInnerException = \"{0}{1}: {2}Message: {3}{4}StackTrace: {5}.\";
private const string ExceptionMessageWithInnerException = \"{0}{1}{2}\";
#endregion
#region ===[ Properties ]==================================================================
///
///
public static Logger Instance
{
get { return _loggerInstance.Value; }
}
#endregion
#region ===[ ILogger Members ]=============================================================
///
///
///
public void Debug(object message)
{
if (_logger.IsDebugEnabled)
_logger.Debug(message);
}
///
///
///
public void Info(object message)
{
if (_logger.IsInfoEnabled)
_logger.Info(message);
}
///
///
///
public void Warn(object message)
{
if (_logger.IsWarnEnabled)
_logger.Warn(message);
}
///
///
///
public void Error(object message)
{
_logger.Error(message);
}
///
///
///
public void Fatal(object message)
{
_logger.Fatal(message);
}
///
///
///
///
public void Debug(object message, Exception exception)
{
if (_logger.IsDebugEnabled)
_logger.Debug(message, exception);
}
///
///
///
///
public void Info(object message, Exception exception)
{
if (_logger.IsInfoEnabled)
_logger.Info(message, exception);
}
///
///
///
///
public void Warn(object message, Exception exception)
{
if (_logger.IsWarnEnabled)
_logger.Info(message, exception);
}
///
///
///
///
public void Error(object message, Exception exception)
{
_logger.Error(message, exception);
}
///
///
///
///
public void Fatal(object message, Exception exception)
{
_logger.Fatal(message, exception);
}
///
///
///
public void Error(Exception exception)
{
_logger.Error(SerializeException(exception, ExceptionName));
}
///
///
///
public void Fatal(Exception exception)
{
_logger.Fatal(SerializeException(exception, ExceptionName));
}
#endregion
#region ===[ Public Methods ]==============================================================
///
///
///
///
public static string SerializeException(Exception exception)
{
return SerializeException(exception, string.Empty);
}
#endregion
#region ===[ Private Methods ]=============================================================
///
///
///
///
///
private static string SerializeException(Exception ex, string exceptionMessage)
{
var mesgAndStackTrace = string.Format(ExceptionMessageWithoutInnerException, Environment.NewLine,
exceptionMessage, Environment.NewLine, ex.Message, Environment.NewLine, ex.StackTrace);
if (ex.InnerException != null)
{
mesgAndStackTrace = string.Format(ExceptionMessageWithInnerException, mesgAndStackTrace,
Environment.NewLine,
SerializeException(ex.InnerException, InnerExceptionName));
}
return mesgAndStackTrace + Environment.NewLine;
}
#endregion
}
设置 SQL 项目:添加一个新的类库项目 ( CleanArch.Sql)。我们将使用此项目来管理 Dapper 查询。
• 添加一个新文件夹Queries并在其下添加一个新类ContactQueries(以管理对象的简洁查询Contact)。
public static class ContactQueries
{
public static string AllContact => \"SELECT * FROM [Contact] (NOLOCK)\";
public static string ContactById => \"SELECT * FROM [Contact] (NOLOCK) WHERE [ContactId] = @ContactId\";
public static string AddContact =>
@\"INSERT INTO [Contact] ([FirstName], [LastName], [Email], [PhoneNumber])
VALUES (@FirstName, @LastName, @Email, @PhoneNumber)\";
public static string UpdateContact =>
@\"UPDATE [Contact]
SET [FirstName] = @FirstName,
[LastName] = @LastName,
[Email] = @Email,
[PhoneNumber] = @PhoneNumber
WHERE [ContactId] = @ContactId\";
public static string DeleteContact => \"DELETE FROM [Contact] WHERE [ContactId] = @ContactId\";
}
• 除此之外,Scripts还添加了包含示例中使用的表的先决条件脚本的文件夹。
设置基础设施层:由于我们的基础代码已经准备好,现在添加一个新的类库项目并将其命名为CleanArch.Infrastructure。
• 添加本项目需要使用的包。
Install-Package Dapper
Install-Package Microsoft.Extensions.Configuration
Install-Package Microsoft.Extensions.DependencyInjection.Abstractions
Install-Package System.Data.SqlClient
• 添加对项目的引用(Application、、Core和Sql),并添加一个新文件夹Repository。
• 之后IContactRepository,让我们通过创建一个新类ContactRepository并注入来IConfiguration获取连接字符串来实现接口appsettings.json
public class ContactRepository : IContactRepository
{
#region ===[ Private Members ]=============================================================
private readonly IConfiguration configuration;
#endregion
#region ===[ Constructor ]=================================================================
public ContactRepository(IConfiguration configuration)
{
this.configuration = configuration;
}
#endregion
#region ===[ IContactRepository Methods ]==================================================
public async Task<IReadOnlyList> GetAllAsync()
{
using (IDbConnection connection = new SqlConnection(configuration.GetConnectionString(\"DBConnection\")))
{
var result = await connection.QueryAsync(ContactQueries.AllContact);
return result.ToList();
}
}
public async Task GetByIdAsync(long id)
{
using (IDbConnection connection = new SqlConnection(configuration.GetConnectionString(\"DBConnection\")))
{
var result = await connection.QuerySingleOrDefaultAsync(ContactQueries.ContactById, new { ContactId = id });
return result;
}
}
public async Task AddAsync(Contact entity)
{
using (IDbConnection connection = new SqlConnection(configuration.GetConnectionString(\"DBConnection\")))
{
var result = await connection.ExecuteAsync(ContactQueries.AddContact, entity);
return result.ToString();
}
}
public async Task UpdateAsync(Contact entity)
{
using (IDbConnection connection = new SqlConnection(configuration.GetConnectionString(\"DBConnection\")))
{
var result = await connection.ExecuteAsync(ContactQueries.UpdateContact, entity);
return result.ToString();
}
}
public async Task DeleteAsync(long id)
{
using (IDbConnection connection = new SqlConnection(configuration.GetConnectionString(\"DBConnection\")))
{
var result = await connection.ExecuteAsync(ContactQueries.DeleteContact, new { ContactId = id });
return result.ToString();
}
}
#endregion
}
• 另外,IUnitOfWork通过创建新类来实现接口UnitOfWork
public class UnitOfWork : IUnitOfWork
{
public UnitOfWork(IContactRepository contactRepository)
{
Contacts = contactRepository;
}
public IContactRepository Contacts { get; set; }
}
• 最后,将接口及其实现注册到 .NET Core 服务容器。添加一个新的静态类ServiceCollectionExtension,并通过注入的方式在其下添加 RegisterServices 方法IServiceCollection。
• 稍后,我们将在 API 的ConfigureService 方法下注册它。
public static class ServiceCollectionExtension
{
public static void RegisterServices(this IServiceCollection services)
{
services.AddTransient();
services.AddTransient();
}
}
设置 API 项目: 添加一个新的 .NET 8.0 Web API 项目并将其命名为CleanArch.Api。
• 添加对项目的引用(Application、Infrastructure和Logging),并添加Swashbuckle.AspNetCore包。
• 设置appsettings.json文件来管理 API 设置并替换ConnectionStrings部分下的 DB 连接字符串。
{
\"Environment\": \"Development\",
\"EnvironmentVersion\": \"1.0.0\",
\"ConnectionStrings\": {
\"DBConnection\": \"Data Source=; Initial Catalog=; User ID=; PWD=\"
},
\"SecretKeys\": {
\"ApiKey\": \"04577BA6-3E32-456C-B528-E41E20D28D79\",
\"ApiKeySecondary\": \"6D5D1ABA-4F78-4DD3-A69D-C2D15F2E259A,709C95E7-F59D-4CC4-9638-4CDE30B2FCFD\",
\"UseSecondaryKey\": true
},
\"Logging\": {
\"LogLevel\": {
\"Default\": \"Information\",
\"Microsoft.AspNetCore\": \"Warning\"
}
},
\"AllowedHosts\": \"*\"
}
• 添加 log4net.config 并在其下添加与日志相关的设置。确保将其Copy to Output Directory属性设置为Copy Always。
<!---->
• 配置启动设置,例如 RegisterServices(在CleanArch.Infrastructure项目下定义)、配置 log4net 以及添加 Swagger UI(带有身份验证方案)。
using log4net.Config;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
//Configure Log4net.
XmlConfigurator.Configure(new FileInfo(\"log4net.config\"));
//Injecting services -> defined under CleanArch.Infrastructure project.
builder.Services.RegisterServices();
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.AddSecurityDefinition(\"basic\", new OpenApiSecurityScheme
{
Description = \"api key.\",
Name = \"Authorization\",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = \"basic\"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = \"basic\"
},
In = ParameterLocation.Header
},
new List()
}
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
• 删除默认的控制器/模型类并在模型下添加一个新类(ApiResponse),以管理 API 响应的通用响应格式。
public class ApiResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public T? Result { get; set; }
}
• 添加一个新的控制器并将其命名为AuthController,以实现未授权的实现,因为我们将使用基于密钥的身份验证。
[Produces(\"application/json\")]
[Route(\"api/[controller]\")]
[ApiExplorerSettings(IgnoreApi = true)]
public class AuthController : Controller
{
[HttpGet]
public IActionResult NotAuthorized()
{
return Unauthorized();
}
}
• 添加AuthorizationFilter,如下所示,以管理基于 API 密钥的身份验证。
• 这允许基于主密钥和辅助密钥进行身份验证。
• 我们可以添加多个辅助键,并且可以打开或关闭它们的使用appsettings。
• 这将有助于保证我们的主密钥安全,并根据需要将辅助密钥分发给不同的客户端。
public class AuthorizationFilterAttribute : Attribute, IAuthorizationFilter
{
private readonly string _apiKey;
private readonly string _apiKeySecondary;
private readonly bool _canUseSecondaryApiKey;
public AuthorizationFilterAttribute(IConfiguration configuration)
{
_apiKey = configuration[\"SecretKeys:ApiKey\"];
_apiKeySecondary = configuration[\"SecretKeys:ApiKeySecondary\"];
_canUseSecondaryApiKey = configuration[\"SecretKeys:UseSecondaryKey\"] == \"True\";
}
public void OnAuthorization(AuthorizationFilterContext context)
{
var apiKeyHeader = context.HttpContext.Request.Headers[\"Authorization\"].ToString();
var authController = new Controllers.AuthController();
if (apiKeyHeader.Any())
{
var keys = new List
{
_apiKey
};
if (_canUseSecondaryApiKey)
{
keys.AddRange(_apiKeySecondary.Split(\',\'));
}
if (keys.FindIndex(x => x.Equals(apiKeyHeader, StringComparison.OrdinalIgnoreCase)) == -1)
{
context.Result = authController.NotAuthorized();
}
}
else
{
context.Result = authController.NotAuthorized();
}
}
}
• 添加一个新的控制器并将其命名为BaseApiController,该控制器将包含通用实现并将作为所有其他 API 控制器的基础控制器。
[Route(\"api/[controller]\")]
[TypeFilter(typeof(AuthorizationFilterAttribute))]
[ApiController]
public class BaseApiController : ControllerBase
{
}
• 最后,通过注入对象类型IUnitOfWork并添加所有 CRUD 操作来添加一个新的 API 控制器来公开联系人 API。
public class ContactController : BaseApiController
{
#region ===[ Private Members ]=============================================================
private readonly IUnitOfWork _unitOfWork;
#endregion
#region ===[ Constructor ]=================================================================
///
///
public ContactController(IUnitOfWork unitOfWork)
{
this._unitOfWork = unitOfWork;
}
#endregion
#region ===[ Public Methods ]==============================================================
[HttpGet]
public async Task<ApiResponse<List>> GetAll()
{
var apiResponse = new ApiResponse<List>();
try
{
var data = await _unitOfWork.Contacts.GetAllAsync();
apiResponse.Success = true;
apiResponse.Result = data.ToList();
}
catch (SqlException ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"SQL Exception:\", ex);
}
catch (Exception ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"Exception:\", ex);
}
return apiResponse;
}
[HttpGet(\"{id}\")]
public async Task<ApiResponse> GetById(int id)
{
var apiResponse = new ApiResponse();
try
{
var data = await _unitOfWork.Contacts.GetByIdAsync(id);
apiResponse.Success = true;
apiResponse.Result = data;
}
catch (SqlException ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"SQL Exception:\", ex);
}
catch (Exception ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"Exception:\", ex);
}
return apiResponse;
}
[HttpPost]
public async Task<ApiResponse> Add(Contact contact)
{
var apiResponse = new ApiResponse();
try
{
var data = await _unitOfWork.Contacts.AddAsync(contact);
apiResponse.Success = true;
apiResponse.Result = data;
}
catch (SqlException ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"SQL Exception:\", ex);
}
catch (Exception ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"Exception:\", ex);
}
return apiResponse;
}
[HttpPut]
public async Task<ApiResponse> Update(Contact contact)
{
var apiResponse = new ApiResponse();
try
{
var data = await _unitOfWork.Contacts.UpdateAsync(contact);
apiResponse.Success = true;
apiResponse.Result = data;
}
catch (SqlException ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"SQL Exception:\", ex);
}
catch (Exception ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"Exception:\", ex);
}
return apiResponse;
}
[HttpDelete]
public async Task<ApiResponse> Delete(int id)
{
var apiResponse = new ApiResponse();
try
{
var data = await _unitOfWork.Contacts.DeleteAsync(id);
apiResponse.Success = true;
apiResponse.Result = data;
}
catch (SqlException ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"SQL Exception:\", ex);
}
catch (Exception ex)
{
apiResponse.Success = false;
apiResponse.Message = ex.Message;
Logger.Instance.Error(\"Exception:\", ex);
}
return apiResponse;
}
#endregion
}
设置测试项目: 添加一个新的 MSTest 测试项目并命名它CleanArch.Test并添加以下包。
Install-Package Microsoft.Extensions.Configuration
Install-Package MSTest.TestFramework
Install-Package MSTest.TestAdapter
Install-Package Moq
• 之后创建一个新类ContactControllerShould并设置所有可能的测试用例,检查CleanArch.Test项目的代码以进一步了解。
• 在解决方案资源管理器中查看项目结构。
构建并运行测试用例:
构建解决方案并运行代码覆盖率,这将运行所有测试用例并向您显示测试代码覆盖率。
运行并测试 API:
运行项目并测试所有 CRUD API 方法。(确保CleanArch.Api将其设置为启动项目)
Swagger 用户界面
未经身份验证运行 API 会引发错误。
添加 API 授权。
POST——添加新记录。
GET——获取所有记录。
PUT——更新现有记录。
GET——获取单条记录。
删除——删除现有记录。
好了,本文到此结束。
如果您喜欢此文章,请收藏、点赞、评论,谢谢,祝您快乐每一天。