0%

背景

持续测试作为持续集成的一部分,对代码质量起到了把关作用。而测试覆盖率作为衡量测试代码质量的重要指标,在大型项目发布、维护过程中是必不可少的。
对于使用Git进行版本管理的项目,通过引入Codecov可以得到多个维度的测试统计报表,并加以自动化代码质量检查。

思路

使用Codecov的前提仅是拥有测试代码,但配置自动发布管道可让Codecov的集成更为方便。因此本篇博文以GitHub Actions为例,演示一个托管在GitHub上的项目引入Codecov的简单方式。

编码

  1. 假设仓库由一个JavaScript项目构成,并有对应的Jest测试代码,则需要创建的构建管道如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//.github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
test-js:
runs-on: ubuntu-latest
name: Test js code
defaults:
run:
working-directory: ./js
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v3.1.1
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v3

该管道中的GitHub Action codecov/codecov-action负责将Jest测试套件输出的测试统计结果转化成Codecov平台支持的XML报表格式,并上传至Codecov服务端。

Codecov在管道运行中输出的日志

  1. 若需要对Pull Request进行代码覆盖率检查,则需要在根目录创建的Codecov配置文件如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // Codecov.yml
    coverage:
    status:
    project:
    default:
    target: 100%
    threshold: 10%
    patch:
    default:
    target: 100%
    threshold: 10%
  2. 若仓库有另一个.NET项目,且需要对两个项目分别配置不同的测试覆盖率检查,则构建管道及Codecov配置文件的改动如下所示:

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
//.github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
test-js:
runs-on: ubuntu-latest
name: Test js code
defaults:
run:
working-directory: ./js
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v3.1.1
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v3
test-dotnet:
runs-on: ubuntu-latest
name: Test dotnet divide function
defaults:
run:
working-directory: ./dotnet/Math.Test
steps:
- uses: actions/checkout@v1
- uses: actions/setup-dotnet@v1.9.0
with:
dotnet-version: '6.0.x'
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --no-restore --collect:"XPlat Code Coverage"
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Codecov.yml
coverage:
status:
project:
default: false
js:
target: 100%
threshold: 10%
paths:
- js/
dotnet:
target: auto
threshold: 10%
paths:
- dotnet/
patch:
default: false
  1. 若需要给项目README添加测试覆盖率徽章,可在Codecov控制台中获取Markdown链接:

在Codecov控制台中获取徽章Markdown
在README中放置徽章

  1. 除了使用不同的Status配置,还可以使用Codecov的Flag特性来更定制化地生成独立的测试统计报表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Codecov.yml
coverage:
status:
project: off
patch: off

flag_management:
individual_flags:
- name: js
paths:
- js/
statuses:
- type: project
target: 100%
threshold: 10%
- name: dotnet
paths:
- dotnet/Math
statuses:
- type: project
target: auto
threshold: 10%
  1. 更多配置项及特性,可在Codecov官方文档中了解

背景

前篇博文介绍了DevExpress Reporting在ASP.NET Core服务端中实现自定义扩展的用例,本篇博文将分享一个在JavaScript客户端中实现报表间跳转、返回的方法。

思路

对于DocumentViewer,DevExpress的JavaScript包提供了若干种事件的回调钩子,其中一个名为PreviewClick的回调可以用于实现自定义的跳转逻辑。另外我们将利用另一个事件DocumentReady实现返回后自动滚动至跳转前的页数的逻辑。

编码

  1. 在我们引入DevExpress Reporting时创建的viewerOptions中,添加PreviewClick回调函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PreviewClick: function (s, e) {
var brick = e.Brick;
var navigationUrl = brick && brick.navigation && brick.navigation.url;
var navigationTarget = brick && brick.navigation && brick.navigation.target;

if (navigationTarget && navigationTarget.trim().toUpperCase() === 'REPORT' && navigationUrl) {
var reportPreview = s.GetReportPreview();
var isBack = navigationUrl.trim().toUpperCase() === 'BACK';
var navigateInfo = {
ReportUrl: navigationUrl,
IsBack: isBack,
SourcePageIndex: isBack ? null : s.GetCurrentPageIndex()
};

reportPreview.drillThrough(JSON.stringify(navigateInfo));
e.Handled = true;
}
}

博主在这段代码中,默认支持报表在设计时,通过指定Navigation Url为报表数据Url,Navigation Target为”Report”或”Back”(大小写不敏感),以支持非常简单的跳转/返回设置。

  1. 当从多页报表中跳转到子报表后需要返回至原页,我们可通过一个在后端保存并传递的报表参数在DocumentReady回调中获取原页数。博主在这里使用PageIndex作为参数名。后端的具体逻辑可参考上篇博文DevExpress Reporting - ASP.NET Core项目中实现参数校验
1
2
3
4
5
6
7
8
9
10
11
DocumentReady: function (s, e) {
var previewModel = s.GetPreviewModel();
var parametersModel = s.GetParametersModel();
var pageIndexParameter = parametersModel.serializeParameters().find(p => p.Key === 'PageIndex');

if (pageIndexParameter) {
setTimeout(() => {
previewModel.GoToPage(Math.max(pageIndexParameter.Value, 0));
}, 200);
}
}

背景

DevExpress Reporting是DevExpress产品系列下的一个重要的模块,能在WinForms,WPS,ASP.NET,Blazor等众多平台下帮助用户快速创建美观的报告、报表。在构建通用程度高的报表时,往往需要接收参数以生成对应条件下的报表。本片博文将分享在ASP.NET Core中如何实现定制化的报表参数校验机制。

思路

DevExpress Reporting的SDK提供了一个叫WebDocumentViewerOperationLogger的类,根据名字也可推断这个类是作为Viewer生成过程中的日志方法的基类。通过简单的反编译可以发现这个类包含了众多标记为virtual方法,基本涵盖了从报表打开到报表实例销毁的整个生命周期,而这个基类本身对各个方法基本是空实现,所以从设计上看这个类就是专门为用户自定义扩展而存在的。

编码

  1. 创建继承WebDocumentViewerOperationLogger的子类CustomWebDocumentViewerOperationLogger
1
2
3
4
5
6
7
8
9
10
11
12
13
public class CustomWebDocumentViewerOperationLogger : WebDocumentViewerOperationLogger
{
private readonly IReportInterceptorService _reportInterceptorService;

...

public override async Task<Action> BuildStartingAsync(string reportId, string reportUrl, XtraReport report, ReportBuildProperties buildProperties)
{
await _reportInterceptorService.PreBuildStartAsync(report.Name, buildProperties.Parameters);
return null;
}
...
}

在这里博主只演示重写BuildStartingAsync的方法,读者可自行选择生命周期事件进行重写。博主使用了一个另外一个服务类来提供具体的事件处理业务。需要注意的是方法签名中的4个参数,其中report参数提供了当前报表文档示例的所有属性,而buildProperties参数中提供了用户输入的报表参数。

  1. 在具体业务逻辑类中进行参数校验:
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
public class DonationTaxSummaryReportInterceptor : IReportInterceptorService
{
public override Task PreBuildStartAsync(string reportName, Dictionary<string, object> parameters)
{
var dateRange = GetDateRange(parameters);
if (!dateRange.HasValue)
{
throw new ArgumentException("Please select a year range.");
}

return Task.CompletedTask;
}

private (DateTime, DateTime)? GetDateRange(Dictionary<string, object> parameters)
{
var years = parameters["Years"] as string;
if (string.IsNullOrWhiteSpace(years) || year.Length != 8)
{
return null;
}

if (int.TryParse(years[..4], out var yearFrom) && int.TryParse(years[^4..], out var yearTo))
{
var dateFrom = new DateTime(yearFrom, 4, 1);
var dateTo = new DateTime(yearTo, 3, 31);

return (dateFrom, dateTo);
}

return null;
}
}

这里以校验一个必需的年份范围参数为例,若校验失败,则直接抛出异常,因为这个类并不应具有处理异常的能力。

  1. 参数校验完毕后,如果有失败的结果以异常的形式向上抛出,我们可以在上层捕获,处理为用户友好文字后,最终以DocumentCreationException的形式抛出。DocumentCreationException类是DevExpress的SDK中定义的异常类,在文档实例生命周期中抛出的该类型的类会被DevExpress处理为前端页面可识别的异常,最终显示在用户界面上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void HandleDocumentBuildException(Exception e)
{
string message;
if (e is AggregateException aggregateException)
{
message = aggregateException.InnerExceptions.First().Message;
}
else
{
message = e.Message;
}

throw new DocumentCreationException(string.IsNullOrEmpty(message) ? "Internal Server Error" : message);
}

背景

使用CI/CD及docker容器部署一个简单的web应用时,利用docker context可以简单快速地进行远程镜像部署,而无需任何镜像托管服务进行发布和拉取。本篇博文中的例子将以Nginx作为反向代理服务器。

思路

配置好生产服务器的SSH连接后,即可利用docker context进行远程容器部署。其中前端应用由于只是静态文件,在GitHub Action中编译后直接上传至远程服务器。
以下例子采用

  • 前端:Angular应用
  • 后端:.NET Core WebAPI
  • 反向代理:Nginx

编码

  1. Dockerfile编写

    • .NET Core服务端

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
      WORKDIR /app
      EXPOSE 80

      FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
      WORKDIR /src
      COPY ["My.Server.csproj", "My.Server/"]
      RUN dotnet restore "My.Server/My.Server.csproj"
      COPY . My.Server/
      WORKDIR "/src/My.Server"
      RUN dotnet build "My.Server.csproj" -c Release -o /app/build

      FROM build AS publish
      RUN dotnet publish "My.Server.csproj" -c Release -o /app/publish

      FROM base AS final
      WORKDIR /app
      COPY --from=publish /app/publish .
      ENTRYPOINT ["dotnet", "My.Server.dll"]
    • Angular应用

      1
      2
      3
      4
      5
      6
      7
      8
      FROM node:16 AS build
      WORKDIR /usr/local/app
      COPY . .
      RUN npm install && npm run build:prod

      FROM nginx:latest
      COPY --from=build /usr/local/app/dist/apps/myapp /usr/share/nginx/html
      EXPOSE 80
  2. docker-compose编写

  • 为了方便在服务运行时直接修改Nginx配置而不需要重启容器,博主在相应的service中进行了挂载配置,以让Nginx容器读取服务器本地的配置文件。同样对日志及Web静态文件进行挂载配置。需要在第一次部署前在远程服务器中创建好相应的目录。Nginx配置文件可以一并提前创建好,也可在源码中签入一份默认Nginx配置并在GitHub Actions中上传至远程机。
  • 由于Nginx和后端应用作为两个镜像部署,需要配置network以支持容器间通信。Nginx在宿主机中暴露80端口,后端应用在my_net网络中暴露80端口,可对Nginx依据这些地址进行相应配置。
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
version: '3.7'

services:
nginx:
image: nginx:latest
container_name: my_nginx
volumes:
- /etc/nginx/conf.d/default.prod.conf:/etc/nginx/conf.d/default.conf:ro
- /var/log/nginx/prod:/var/log/nginx
- /usr/share/nginx/html/prod:/usr/share/nginx/html
networks:
my_net:
ipv4_address: 172.30.1.3
ports:
- "80:80"

my-backend:
image: 'my/service:latest'
container_name: my_service
build:
context: ./My.Server/My.Server
dockerfile: ./Dockerfile
environment: # 根据实际需要,在这里提供配置项以覆盖appsettings.json中的配置
- App__BaseUrl=http://my-server-domain-name/api
networks:
my_net:
ipv4_address: 172.30.1.8

networks:
my_net:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.30.1.0/24
gateway: 172.30.1.1
  1. 最后的GitHub Actions文件
    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
    name: Build and Deploy

    # 依据实际情况配置触发条件,这里配置为手动触发模拟正式环境部署
    on: workflow_dispatch

    env:
    ANGULAR_ROOT_DIR: ./My.AngularUI

    jobs:
    build-and-deploy:
    name: Build and Deploy
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2.3.4

    - name: Use Node 16.x
    uses: actions/setup-node@v3.1.1
    with:
    node-version: '16.10.0'

    - name: Install dependencies
    run: npm ci
    working-directory: ${{ env.ANGULAR_ROOT_DIR }}

    - name: Build Angular
    run: npm run build:prod
    working-directory: ${{ env.ANGULAR_ROOT_DIR }}

    - name: Deploy angular to production server
    uses: appleboy/scp-action@v0.1.2
    with:
    host: ${{ secrets.REMOTE_HOST }}
    username: ${{ secrets.REMOTE_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    source: "${{ env.ANGULAR_ROOT_DIR }}/dist/apps/myapp/*"
    target: ${{ secrets.FRONT_TARGET_PATH_PROD }}
    strip_components: 4

    # 这里配置pipeline中的SSH公私钥及远程地址。
    - name: Setup ssh key
    run: |
    mkdir -p "$HOME/.ssh"

    printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY}}" > "$HOME/.ssh/id_rsa"
    chmod 600 "$HOME/.ssh/id_rsa"

    printf '%s\n' "${{ secrets.SSH_PUBLIC_KEY}}" > "$HOME/.ssh/id_rsa.pub"
    chmod 600 $HOME/.ssh/id_rsa.pub

    ssh-keyscan -p 22 "${{ secrets.REMOTE_HOST }}" >> "$HOME/.ssh/known_hosts"

    - name: Docker compose up
    run: |
    docker context create remote --docker "host=ssh://${{ secrets.REMOTE_USER }}@${{ secrets.REMOTE_HOST }}"
    docker context use remote
    docker compose -f docker-compose.prod.yml up -d --build

背景

在开发一个大型.Net系统时,可能会需要根据项目类型拆分为多个.sln解决方案,以避免单个解决方案包含数十个项目带来的开发和管理上的困难,典型如系统包含底层库和下游应用的复杂系统。但是Visual Studio对跨解决方案的项目引用场景支持并不是特别完善,我们可选择使用脚本来对整个项目进行完整的编译操作。

思路

借鉴Abp框架源码中的脚本文件,我们可以使用Powershell命令,遍历所有.sln解决方案,并对每个解决方案调用dotnet build。

编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$solutionPaths = @(
{你的解决方案文件路径数组}
)

foreach ($solutionPath in $solutionPaths) {
$solutionAbsPath = (Join-Path $rootFolder $solutionPath)
Set-Location $solutionAbsPath
dotnet build
if (-Not $?) {
Write-Host ("Build failed for the solution: " + $solutionPath)
Set-Location $rootFolder
exit $LASTEXITCODE
}
}

Set-Location $rootFolder

为了更好的管理,还可以建立一个单独的Powershell存放各解决方案的路径字符串:

(build-all.ps1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$full = $args[0] 

. ".\common.ps1" $full

Write-Host $solutionPaths

foreach ($solutionPath in $solutionPaths) {
$solutionAbsPath = (Join-Path $rootFolder $solutionPath)
Set-Location $solutionAbsPath
dotnet build
if (-Not $?) {
Write-Host ("Build failed for the solution: " + $solutionPath)
Set-Location $rootFolder
exit $LASTEXITCODE
}
}

Set-Location $rootFolder

(common.ps1)

1
2
3
4
5
6
7
$full = $args[0]

$rootFolder = (Get-Item -Path "./" -Verbose).FullName

$solutionPaths = @(
{你的解决方案文件路径数组}
)

其他系列博文

背景

在上一篇博文中,我们建立了状态切换/处理源状态/目标状态的解耦,实现了一个基于Abp的状态管理模块,在这一篇中博主将对该机制编写单元测试。

目标

  • 对状态定义与状态处理两个核心功能进行测试。

思路

在创建测试状态类时,覆盖以下情形:

  • 父子状态层次结构
  • 状态切换分支
  • 多对多切换

编码

  1. 创建测试状态定义类TestStateDefinitionProvider,与状态名称静态类TestStateNames

    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
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    public class TestStateDefinitionProvider : StateDefinitionProvider
    {
    public override void Define(IStateDefinitionContext context)
    {
    var deviceGroup = context
    .AddGroup(TestStateNames.RootGroup)
    .AddGroup(TestStateNames.DeviceGroup);

    var onlineGroup = deviceGroup.AddGroup(TestStateNames.OnlineGroup);
    var normalGroup = onlineGroup.AddGroup(TestStateNames.NormalGroup);
    var faultGroup = onlineGroup.AddGroup(TestStateNames.FaultGroup);

    var normalOpenState = normalGroup.AddState(TestStateNames.NormalOpenState);
    var normalCloseState = normalGroup.AddState(TestStateNames.NormalCloseState);
    var faultOpenState = faultGroup.AddState(TestStateNames.FaultOpenState);
    var faultCloseState = faultGroup.AddState(TestStateNames.FaultCloseState);

    var offlineState = deviceGroup.AddState(TestStateNames.OfflineState);

    onlineGroup
    .On(TestStateNames.OfflineTerm)
    .TransitTo(offlineState);

    offlineState
    .On(TestStateNames.NormalCloseTerm)
    .TransitTo(normalCloseState)
    .On(TestStateNames.FaultCloseTerm)
    .TransitTo(faultCloseState)
    .On(TestStateNames.NormalOpenTerm)
    .TransitTo(normalOpenState)
    .On(TestStateNames.FaultOpenTerm)
    .TransitTo(faultOpenState);

    normalOpenState
    .On(TestStateNames.NormalCloseTerm)
    .TransitTo(normalCloseState)
    .ReverseOn(TestStateNames.NormalOpenTerm)
    .On(TestStateNames.FaultOpenTerm)
    .TransitTo(faultOpenState)
    .ReverseOn(TestStateNames.NormalOpenTerm);

    normalCloseState
    .On(TestStateNames.FaultCloseTerm)
    .TransitTo(faultCloseState)
    .ReverseOn(TestStateNames.NormalCloseTerm);

    faultOpenState
    .On(TestStateNames.FaultCloseTerm)
    .TransitTo(faultCloseState)
    .ReverseOn(TestStateNames.FaultOpenTerm);
    }
    }

    public static class TestStateNames
    {
    public const string RootGroup = "Root";
    public const string DeviceGroup = "Device";

    public const string OnlineGroup = DeviceGroup + ".Online";
    public const string NormalGroup = OnlineGroup + ".Normal";
    public const string FaultGroup = OnlineGroup + ".Fault";

    public const string NormalOpenState = NormalGroup + ".Open";
    public const string NormalCloseState = NormalGroup + ".Close";
    public const string FaultOpenState = FaultGroup + ".Open";
    public const string FaultCloseState = FaultGroup + ".Close";

    public const string OfflineState = DeviceGroup + ".Offline";

    public const string OfflineTerm = "Offline";
    public const string NormalOpenTerm = "NormalOpen";
    public const string NormalCloseTerm = "NormalClose";
    public const string FaultOpenTerm = "FaultOpen";
    public const string FaultCloseTerm = "FaultClose";
    }
  2. 创建测试类StatesDefinition_Tests测试状态定义功能:

    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
    public class StatesDefinition_Tests : StatesTestBase
    {
    private readonly IStateDefinitionManager _stateDefinitionManager;
    private readonly IConfiguration _configuration;

    public StatesDefinition_Tests()
    {
    _stateDefinitionManager = GetRequiredService<IStateDefinitionManager>();
    _configuration = GetRequiredService<IConfiguration>();
    }

    [Fact]
    public void Should_Register_States_Using_Provider()
    {
    var deviceGroup = _stateDefinitionManager.GetGroupRecursivelyOrNull(TestStateNames.DeviceGroup);
    var onlineGroup = _stateDefinitionManager.GetGroupRecursivelyOrNull(TestStateNames.OnlineGroup);
    var normalGroup = _stateDefinitionManager.GetGroupRecursivelyOrNull(TestStateNames.NormalGroup);
    var faultGroup = _stateDefinitionManager.GetGroupRecursivelyOrNull(TestStateNames.FaultGroup);

    deviceGroup.ShouldNotBeNull();
    onlineGroup.ShouldNotBeNull();
    normalGroup.ShouldNotBeNull();
    faultGroup.ShouldNotBeNull();

    deviceGroup.States.Count.ShouldBe(1);
    onlineGroup.States.Count.ShouldBe(0);
    normalGroup.States.Count.ShouldBe(2);
    faultGroup.States.Count.ShouldBe(2);

    onlineGroup.GetAllStates().Count.ShouldBe(4);

    deviceGroup.States.SelectMany(state => state.Transitions).Count().ShouldBe(4);
    normalGroup.States.SelectMany(state => state.Transitions).Count().ShouldBe(6);
    faultGroup.States.SelectMany(state => state.Transitions).Count().ShouldBe(6);
    }

    [Fact]
    public void Should_Get_Correct_Group_Belongings()
    {
    _stateDefinitionManager.IsGroupBelongTo(TestStateNames.OnlineGroup, TestStateNames.DeviceGroup).ShouldBeTrue();
    _stateDefinitionManager.IsGroupBelongTo(TestStateNames.NormalGroup, TestStateNames.DeviceGroup).ShouldBeTrue();
    _stateDefinitionManager.IsGroupBelongTo(TestStateNames.OnlineGroup, TestStateNames.NormalGroup).ShouldBeFalse();
    _stateDefinitionManager.IsGroupBelongTo(TestStateNames.OnlineGroup, TestStateNames.OnlineGroup).ShouldBeTrue();
    _stateDefinitionManager.IsGroupBelongTo(TestStateNames.OnlineGroup, TestStateNames.OnlineGroup, false).ShouldBeFalse();
    }

    [Fact]
    public void Should_Read_State_Definition_From_Configuration()
    {
    var configuration = _configuration;
    }
    }
  3. 创建测试类StatesHandler_Tests测试状态切换处理功能:

    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
    public class StatesHandler_Tests : StatesTestBase
    {
    private readonly IStateTransitionNotifier _stateTransitionNotifier;
    private readonly Func<IDictionary<StateTransitionDefinition, bool>, string> _postHandleAction;
    private readonly Action<StateTransitionContext> _contextAction;

    public StatesHandler_Tests()
    {
    _stateTransitionNotifier = GetRequiredService<IStateTransitionNotifier>();

    _postHandleAction = handlerContext => handlerContext.Values.Where(value => value).Count().ToString();
    _contextAction = context => context.SetProperty(TestStateTransitionHandlerBase.PostHandleActionKey, _postHandleAction);
    }

    [Fact]
    public async Task Should_Notify_Within_Given_Group_By_Default()
    {
    await _stateTransitionNotifier.NotifyAsync(TestStateNames.DeviceGroup, TestStateNames.NormalCloseTerm, _contextAction);
    RecordLatestStateTransitionHandler.LatestTransition.Count.ShouldBe(3);
    RecordLatestStateTransitionHandler.HandleResult.ShouldBe("3");

    await _stateTransitionNotifier.NotifyAsync(TestStateNames.NormalGroup, TestStateNames.NormalCloseTerm, _contextAction);
    RecordLatestStateTransitionHandler.LatestTransition.Count.ShouldBe(1);
    RecordLatestStateTransitionHandler.HandleResult.ShouldBe("1");
    }

    [Fact]
    public async Task Should_Break_When_All_Transitions_Are_Handled_By_Default()
    {
    await _stateTransitionNotifier.NotifyAsync(TestStateNames.DeviceGroup, TestStateNames.NormalCloseTerm, _contextAction);
    UnReachableStateTransitionHandler.HandleResult.ShouldBeNull();
    }

    [Fact]
    public async Task Should_Not_Handle_If_Notified_Group_Is_Larger()
    {
    await _stateTransitionNotifier.NotifyAsync(TestStateNames.RootGroup, TestStateNames.NormalCloseTerm, _contextAction);
    RecordLatestStateTransitionHandler.LatestTransition.Count.ShouldBe(0);
    RecordLatestStateTransitionHandler.HandleResult.ShouldBeNull();
    }
    }
  4. 测试写完了,我们来跑一下:

测试结果

完美通过。

至此状态管理模块的开发与测试已全部完毕。

其他系列博文

背景

通常在各种状态维护业务中,状态切换与处理逻辑会放在一起处理,具有较强耦合性。若使用订阅发布的中介者模式,如MediatR等工具,能减少状态切换与处理动作之间的强耦合。但在一个有限状态机模型中,还有一层源状态与目标状态的对应关系,这层关系很难通过使用MediatR等通用工具直接达到解耦目的,尤其在更为复杂的状态机模型中更是如此。设想一个设备管理系统,要维护一个在线/异常/低电量/休眠/离线/禁用的二维状态模型,存在一对多的切换路径,且需要支持状态递归切换。我们试着建立一个基于Abp的抽象模块来提供针对性的中介者工具。

目标

支持状态切换/处理源状态/目标状态两层解耦,但不假设任何具体业务或持久化方式,以提供足够的抽象。

思路

对于两个解耦层:

  • 源状态/目标状态 - 遵循Abp的Settings等模块风格,实现声明式自注册
  • 状态切换/处理 - 实现类中介者模式的订阅发布功能

另外并支持状态组声明,实现状态层级结构,以应对更复杂的业务模型

编码

首先创建用以声明状态的StateDefinition类:

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class StateDefinition : IState
{
public string Name { get; }

public StateType Type { get; }

public ILocalizableString DisplayName
{
get => _displayName;
set => _displayName = Check.NotNull(value, nameof(value));
}
private ILocalizableString _displayName;

internal List<StateTransitionDefinition> Transitions { get; } = new List<StateTransitionDefinition>();

public Dictionary<string, object> Properties { get; } = new Dictionary<string, object>();

public object this[string name]
{
get => Properties.GetOrDefault(name);
set => Properties[name] = value;
}

public StateDefinition(
string name,
ILocalizableString displayName = null,
StateType type = StateType.Intermediate)
{
Name = Check.NotNullOrWhiteSpace(name, nameof(name));
DisplayName = displayName ?? new FixedLocalizableString(name);
Type = type;
}

public virtual StateTransitionDefinition On(params string[] terms)
{
Check.NotNullOrEmpty(terms, nameof(terms));
return new StateTransitionDefinition(this, terms.Select(term => Check.NotNullOrWhiteSpace(term, nameof(terms))));
}

public virtual StateDefinition ReverseOn(params string[] terms)
{
Check.NotNullOrEmpty(terms, nameof(terms));
var lastTransition = Transitions.LastOrDefault();
if (lastTransition == null)
{
throw new MyStatesException("No state transition to be reversed.");
}

lastTransition.TargetState.On(terms).TransitTo(this);
return this;
}

public virtual StateDefinition WithProperty(string key, object value)
{
Properties[key] = value;
return this;
}

public virtual bool HasTargetState(StateDefinition targetState)
{
return Transitions.Any(transition => transition.TargetState.Equals(targetState));
}

public override bool Equals(object obj)
{
if (obj == null) return false;
if (ReferenceEquals(this, obj)) return true;
if (!obj.GetType().IsAssignableTo<StateDefinition>()) return false;

return Name == obj.As<StateDefinition>()?.Name;
}

public override int GetHashCode()
{
return Name.GetHashCode();
}
}

博主给这个类封装了一些常用的帮助方法,并支持了本地化。其中最重要的是Transitions属性,它表明了状态与状态之间可能存在的一对多转换关系。转换关系声明类StateTransitionDefinition如下:

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
60
61
62
63
64
65
66
67
68
69
70
71
public class StateTransitionDefinition
{
public StateDefinition SourceState { get; }

public StateDefinition TargetState { get; protected set; }

public List<string> Terms { get; } = new List<string>();

protected internal StateTransitionDefinition(StateDefinition sourceState, IEnumerable<string> terms)
{
AssertStateAllowTransitFrom(Check.NotNull(sourceState, nameof(sourceState)));

SourceState = sourceState;
Terms = terms.ToList();
}

public virtual StateDefinition TransitTo(StateDefinition targetState)
{
AssertStateAllowTransitTo(Check.NotNull(targetState, nameof(targetState)));

TargetState = targetState;
SourceState.Transitions.Add(this);

return SourceState;
}

protected virtual void AssertStateAllowTransitFrom(StateDefinition sourceState)
{
if (sourceState.Type.HasFlag(StateType.Final))
{
throw new MyStatesException("Final state cannot be transited from.", sourceState.Name);
}
}

protected virtual void AssertStateAllowTransitTo(StateDefinition targetState)
{
if (targetState.Type.HasFlag(StateType.Initial))
{
throw new MyStatesException("Initial state cannot be transited to.", targetState.Name);
}

if (Terms.IsNullOrEmpty())
{
throw new MyStatesException("State transition must have terms.", targetState.Name);
}

if (SourceState.HasTargetState(targetState))
{
throw new MyStatesException("Source state already has a same target state.", targetState.Name);
}

if (SourceState.Transitions
.SelectMany(transition => transition.Terms)
.Intersect(Terms)
.Any())
{
throw new MyStatesException("Source state cannot have multiple transitions with same terms", targetState.Name);
}
}

protected virtual void AssertTransitionAllowReverse()
{
if (SourceState == null || TargetState == null)
{
throw new MyStatesException($"{nameof(SourceState)} and {nameof(TargetState)} should not be null when reversing.");
}

AssertStateAllowTransitFrom(TargetState);
AssertStateAllowTransitTo(SourceState);
}
}

其次,创建状态组声明类StateGroupDefinition:

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
public class StateGroupDefinition
{
public string Name { get; }

public StateGroupDefinition Parent { get; private set; }

public ILocalizableString DisplayName
{
get => _displayName;
set => _displayName = Check.NotNull(value, nameof(value));
}
private ILocalizableString _displayName;

public IReadOnlyList<StateDefinition> States => _states.ToImmutableList();
private readonly List<StateDefinition> _states = new();

public IReadOnlyList<StateGroupDefinition> Groups => _groups.ToImmutableList();
private readonly List<StateGroupDefinition> _groups = new();

public Dictionary<string, object> Properties { get; } = new Dictionary<string, object>();

public object this[string name]
{
get => Properties.GetOrDefault(name);
set => Properties[name] = value;
}

protected internal StateGroupDefinition(
string name,
ILocalizableString displayName = null,
StateGroupDefinition parent = null)
{
Name = Check.NotNullOrWhiteSpace(name, nameof(name));
DisplayName = displayName ?? new FixedLocalizableString(Name);
Parent = parent;
}

public virtual StateGroupDefinition AddGroup(
string name,
ILocalizableString displayName = null)
{
if (Name == name || _groups.Any(group => group.Name == name))
{
throw new MyStatesException($"There is already an existing state group definition with given name: {name}", stateGroupName: name);
}

var group = new StateGroupDefinition(name, displayName, this);

_groups.Add(group);
return group;
}

public virtual StateDefinition AddState(
string name,
ILocalizableString displayName = null,
StateType type = StateType.Intermediate)
{
var state = new StateDefinition(name, displayName, type);
return AddState(state);
}

public virtual StateDefinition AddState(StateDefinition state)
{
if (_states.Any(s => s.Name == Check.NotNull(state, nameof(state)).Name))
{
throw new MyStatesException($"There is already an existing state definition with given name: {state.Name}", state.Name, Name);
}

_states.Add(state);
return state;
}

public virtual StateGroupDefinition FindGroupRecursively(string name, bool includeSelf = true)
{
Check.NotNullOrWhiteSpace(name, nameof(name));

if (includeSelf && Name == name)
{
return this;
}

foreach (var childGroup in _groups)
{
var group = EnumerateGroupRecursively(childGroup).FirstOrDefault(g => g.Name == name);
if (group != null)
{
return group;
}
}

return null;
}

public virtual List<StateGroupDefinition> GetAllGroups(bool includeSelf = false)
{
var groups = new List<StateGroupDefinition>();

foreach (var group in _groups)
{
AddGroupToListRecursively(groups, group);
}

if (includeSelf)
{
groups.Insert(0, this);
}

return groups;
}

public virtual List<StateDefinition> GetAllStates()
{
var allGroups = GetAllGroups(true);
return allGroups.SelectMany(group => group.States).ToList();
}

public virtual StateTransitionDefinitionCollection On(params string[] terms)
{
Check.NotNullOrEmpty(terms, nameof(terms));
var allStates = GetAllStates();
return new StateTransitionDefinitionCollection(allStates.Select(state => state.On(terms)).ToList());
}

private void AddGroupToListRecursively(List<StateGroupDefinition> groups, StateGroupDefinition group)
{
groups.Add(group);

foreach (var child in group.Groups)
{
AddGroupToListRecursively(groups, child);
}
}

private IEnumerable<StateGroupDefinition> EnumerateGroupRecursively(StateGroupDefinition group)
{
yield return group;

foreach (var innerGroup in group._groups)
{
foreach (var recurseGroup in EnumerateGroupRecursively(innerGroup))
{
yield return recurseGroup;
}
}
}
}

博主按照Abp的代码风格,创建一个StateDefinitionProvider类与一个StateDefinitionManager类来支持使用上面创建的类进行声明配置,及启动时的配置载入:

1
2
3
4
public abstract class StateDefinitionProvider : IStateDefinitionProvider, ITransientDependency
{
public abstract void Define(IStateDefinitionContext context);
}
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
public class StateDefinitionManager : IStateDefinitionManager, ISingletonDependency
{
protected Lazy<IDictionary<string, StateGroupDefinition>> StateGroupDefinitions { get; }

protected MyStateOptions Options { get; }

protected IServiceProvider ServiceProvider { get; }

public StateDefinitionManager(
IOptions<MyStateOptions> options,
IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
Options = options.Value;

StateGroupDefinitions = new Lazy<IDictionary<string, StateGroupDefinition>>(CreateStateDefinitions, true);
}

...

protected virtual IDictionary<string, StateGroupDefinition> CreateStateDefinitions()
{
var states = new Dictionary<string, StateGroupDefinition>();

using (var scope = ServiceProvider.CreateScope())
{
var providers = Options
.DefinitionProviders
.Select(p => scope.ServiceProvider.GetRequiredService(p) as IStateDefinitionProvider)
.ToList();

foreach (var provider in providers)
{
provider.Define(new StateDefinitionContext(states, scope.ServiceProvider));
}
}

return states;
}
}

接下来进行抽象。整个模块中最核心的是两个抽象:

  • IStateTransitionHandler - 放置状态切换处理逻辑,对应状态切换/处理层解耦
  • IStateTransitionNotifier - 放置状态切换决策逻辑,对应源状态/目标状态层解耦
1
2
3
4
public interface IStateTransitionHandler
{
Task HandleAsync(StateTransitionContext context);
}
1
2
3
4
5
6
7
8
public interface IStateTransitionNotifier
{
Task NotifyAsync(
string groupName,
string term,
Action<StateTransitionContext> contextAction = null,
bool recurse = true);
}

对于IStateTransitionHandler我们可完全交由用户代码实现,而对于IStateTransitionNotifier,博主提供了一个默认实现DefaultStateTransitionNotifier

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class DefaultStateTransitionNotifier : IStateTransitionNotifier, ITransientDependency
{
public ILogger<DefaultStateTransitionNotifier> Logger { get; set; }

protected IServiceProvider ServiceProvider { get; }

protected IStateDefinitionManager DefinationManager { get; }

protected IStateTransitionHandler TransitionHandler { get; }

protected MyStateOptions Options { get; }

public DefaultStateTransitionNotifier(
IServiceProvider serviceProvider,
IStateDefinitionManager definationManager,
IStateTransitionHandler transitionHandler,
IOptions<MyStateOptions> options)
{
ServiceProvider = serviceProvider;
DefinationManager = definationManager;
TransitionHandler = transitionHandler;
Options = options.Value;

Logger = NullLogger<DefaultStateTransitionNotifier>.Instance;
}

public virtual async Task NotifyAsync(
string groupName,
string term,
Action<StateTransitionContext> contextAction = null,
bool recurse = true)
{
Check.NotNullOrWhiteSpace(groupName, nameof(groupName));
Check.NotNullOrWhiteSpace(term, nameof(term));

var group = DefinationManager.GetGroupRecursively(groupName);
var transitions = GetTransitionsToHandle(group, term);
var context = new StateTransitionContext(groupName, term, transitions, recurse);
contextAction?.Invoke(context);

using var scope = ServiceProvider.CreateScope();

var handlers = Options
.TransitionHandlers
.Where(handler =>
{
var handleGroupName = HandleStateAttribute.GetHandleGroupName(handler);
return handleGroupName.IsNullOrEmpty() || DefinationManager.IsGroupBelongTo(groupName, handleGroupName);
})
.Select(p => scope.ServiceProvider.GetRequiredService(p) as IStateTransitionHandler)
.ToList();

foreach (var handler in handlers)
{
await handler.HandleAsync(context);

if (!context.HandlerContext.Any(handlerContext => !handlerContext.Value))
{
break;
}
}

if (!context.HandlerContext.Any(handlerContext => handlerContext.Value))
{
Logger.LogWarning("State transition notification not handled by any of the registered handlers, context: {Context}", context);
}
}

protected virtual IEnumerable<StateTransitionDefinition> GetTransitionsToHandle(StateGroupDefinition group, string term)
{
return group
.GetAllStates()
.SelectMany(state => state.Transitions)
.Where(transition => transition.Terms.Any(t => t == term));
}
}

最后按照惯例创建配置类及模块类,对服务发现及自注册进行支持:

1
2
3
4
5
6
public class MyStateOptions
{
public ITypeList<IStateDefinitionProvider> DefinitionProviders { get; } = new TypeList<IStateDefinitionProvider>();

public ITypeList<IStateTransitionHandler> TransitionHandlers { get; } = new TypeList<IStateTransitionHandler>();
}
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
[DependsOn(typeof(AbpLocalizationModule))]
public class MyStatesModule : AbpModule
{
public override void PreConfigureServices(ServiceConfigurationContext context)
{
AutoAddDefinitionProviders(context.Services);
}

private static void AutoAddDefinitionProviders(IServiceCollection services)
{
var definitionProviders = new List<Type>();
var transitionHandlers = new List<Type>();

services.OnRegistred(context =>
{
if (typeof(IStateDefinitionProvider).IsAssignableFrom(context.ImplementationType))
{
definitionProviders.Add(context.ImplementationType);
}

if (typeof(IStateTransitionHandler).IsAssignableFrom(context.ImplementationType))
{
transitionHandlers.Add(context.ImplementationType);
}
});

services.Configure<MyStateOptions>(options =>
{
options.DefinitionProviders.AddIfNotContains(definitionProviders);
options.TransitionHandlers.AddIfNotContains(transitionHandlers);
});
}
}

至此,基于Abp的状态管理功能已开发完成。在下一篇博文中,博主会对本文实现的功能编写测试。

其他系列博文

背景

在上一篇博文中,我们通过重写DbContext的SaveChanges方式实现了一个基于Abp的按需字段更新机制,在这一篇中博主将对该机制编写单元测试。

目标

  • 创建测试实体,并指定按需更新字段,当调用相关仓储服务的UpdateAsync方法时,若传入值为null,不对其进行更新

思路

创建一个测试用户实体HierarchicalUser,具有HierarchyCodeName两个属性,其中HierarchyCode字段具有较低更新频率,大多数情况下不参与实体更新操作。因此我们可使用上一章中创建的PreventUpdateIfDefault特性对其进行标注,以在集成测试中测试其是否不参与UpdateAsync方法的更新操作。

编码

  1. 创建测试类PartialUpdate_Tests
1
2
3
4
5
6
7
8
9
10
public class PartialUpdate_Tests : EntityFrameworkCoreTestBase
{
// 这里的仓储类是我们在第一篇博文中涉及到的层级用户仓储类
private readonly IHierarchicalUserRepository _userRepository;

public PartialUpdate_Tests()
{
_userRepository = GetRequiredService<IHierarchicalUserRepository>();
}
}
  1. 创建测试方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Fact]
public async Task Should_Prevent_Update_If_HierarchyCode_Of_User_Is_Null()
{
const string fakeHiearchyCode = "1234";
var userId = Guid.NewGuid();

var newUser = new HierarchicalUser(userId, fakeHiearchyCode, "A");
await _userRepository.InsertAsync(newUser, true);

newUser.Name = "B";
newUser.HierarchyCode = null; // 通过赋值null,我们期望HierarchyCode不会参与更新
await _userRepository.UpdateAsync(newUser, true);

var currentUser = await _userRepository.GetAsync(userId);
currentUser.Name.ShouldBe("B"); // 由于Name字段不为按需更新,断言值为新值
currentUser.HierarchyCode.ShouldBe(fakeHiearchyCode); // 由于HierarchyCode字段为按需更新,断言值仍为原值
}
  1. 测试写完了,我们来跑一下:

测试结果

完美通过。

至此数据字段按需更新机制的开发与测试已全部完毕。

其他系列博文

背景

在实际开发场景中,我们经常会遇到更新部分数据字段的需要。然而在Abp的默认关系型数据库ORM(即EF Core)中,并没有提供对数据字段按需更新的实现。本博文将在尽量保证通用性的前提下实现一个简单的EF Core按需更新机制。

目标

支持通过给实体标注特性的方式,引导EF Core更新部分字段。本博文将实现一个更新非默认值字段的机制。

思路

在调用DbContextSaveChanges方法进行数据更新时,EF Core的更改跟踪机制会遍历实体字段获取更改状态信息。通过重写AbpDbContextApplyAbpConceptsForModifiedEntity方法,我们可以获得一个在进行数据库交互前修改字段更改状态的横切点。另一方面,通过利用PropertyEntryMetadata属性,我们可以传入字段的自定义元数据,以引导横切点的判断逻辑。

因此,大致步骤如下:

  1. 创建一个标注特性类
  2. 针对新创建的特性类,封装EF Core的EntityTypeBuilder扩展方法,为具有该特性的实体字段添加元数据
  3. 重写AbpDbContextApplyAbpConceptsForModifiedEntity方法,根据实体字段元数据更新字段更改状态

编码

创建特性类PreventUpdateIfDefaultAttribute,用于标注某字段在默认值时不进行更新:

1
2
3
4
public class PreventUpdateIfDefaultAttribute : Attribute
{

}

在扩展库的EntityTypeBuilderExtensions中,添加一对ConfigurePreventUpdateTryConfigurePreventUpdate方法。如果还没有这个扩展方法类,就在适当位置创建一个,后续还会用到。

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
public static void ConfigureByMyConvention(this EntityTypeBuilder b)
{
//...
b.TryConfigurePreventUpdate();
}

//...

public static void ConfigurePreventUpdate<T>(this EntityTypeBuilder<T> b)
where T : class
{
b.As<EntityTypeBuilder>().TryConfigurePreventUpdate();
}

public static void TryConfigurePreventUpdate(this EntityTypeBuilder b)
{
var properties = b.Metadata.GetProperties();
foreach (var property in properties)
{
if (property.PropertyInfo?.IsDefined(typeof(PreventUpdateIfDefaultAttribute), true) == true)
{
b.Property(property.Name).PreventUpdateIfDefault();
}
}
}

在上述的TryConfigurePreventUpdate方法中,博主对每个标注了PreventUpdateIfDefaultAttribute特性的字段调用了一个PreventUpdateIfDefault扩展方法。这个扩展方法封装了添加元数据的逻辑:

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
public static class MyPropertyBuilderExtensions
{
public const string PreventUpdateConditionAnnotation = "__PreventUpdateCondition";

public static PropertyBuilder<TProperty> PreventUpdate<TProperty>(
this PropertyBuilder<TProperty> propertyBuilder,
Func<object, bool> predicate,
bool prevent = true)
{
return propertyBuilder.HasAnnotation(PreventUpdateConditionAnnotation, prevent ? predicate : null);
}

public static PropertyBuilder PreventUpdate(
this PropertyBuilder propertyBuilder,
Func<object, bool> predicate,
bool prevent = true)
{
return propertyBuilder.HasAnnotation(PreventUpdateConditionAnnotation, prevent ? predicate : null);
}

public static PropertyBuilder<TProperty> PreventUpdateIfDefault<TProperty>(this PropertyBuilder<TProperty> propertyBuilder)
{
var defaultValue = TypeHelper.GetDefaultValue<TProperty>();
return propertyBuilder.PreventUpdate(property => property == null || property.Equals(defaultValue));
}

public static PropertyBuilder PreventUpdateIfDefault(this PropertyBuilder propertyBuilder)
{
var defaultValue = TypeHelper.GetDefaultValue(propertyBuilder.Metadata.ClrType);
return propertyBuilder.PreventUpdate(property => property == null || property.Equals(defaultValue));
}
}

具体而言,对于需要按需更新的字段,博主在PropertyEntry的元数据中加入了一个Func<object, bool>类型的参数,用于在运行时判断是否需要更新。

最后,在我们的DbContext中对Abp基类的方法进行重写:

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
protected override void ApplyAbpConceptsForModifiedEntity(EntityEntry entry, EntityChangeReport changeReport)
{
base.ApplyAbpConceptsForModifiedEntity(entry, changeReport);

//...
ApplyPartiallyUpdateConcept(entry);
}

protected virtual void ApplyPartiallyUpdateConcept(EntityEntry entry)
{
ApplyPartiallyUpdateConceptForProperties(entry.Properties);
}

private void ApplyPartiallyUpdateConceptForProperties(IEnumerable<PropertyEntry> properties)
{
if (properties == null)
{
return;
}

foreach (var property in properties)
{
if (property.Metadata[MyPropertyBuilderExtensions.PreventUpdateConditionAnnotation] is Func<object, bool> predicate)
{
if (predicate(property.CurrentValue))
{
property.IsModified = false;
}
}
}
}

这样一来,在通过EF Core进行数据库更新操作时,程序会执行我们的横切点逻辑,并对每个字段进行默认值检查,只有非默认值的标有特性字段才会执行更新操作。
实际情况中,可能会有其他不同的按需更新逻辑,但是可通过类似的思路进行实现。

至此,基于Abp的EF Core实体字段按需更新功能已开发完成。在下一篇博文中,博主会对本文实现的功能编写测试。

其他系列博文

背景

在上一篇博文中,我们通过使用物化路径模型实现了一个基于Abp的数据权限模块,在这一篇中博主将对模块的几个核心功能编写单元测试。

目标

覆盖以下几点核心功能:

  • 提供默认的全局层级管理器GlobalHierarchyCodeManager注入
  • 层级树中可包含不同节点类型
  • 数据过滤器在配置数据权限后自动开启
  • 数据过滤其能根据用户权限进行正确的数据过滤
  • 未开启数据权限过滤时用户默认应能获取全部数据

思路

GlobalHierarchyCodeManager自身的两个测试放在Hierarchy.Tests项目中,而对于数据过滤器的测试放在EntityFrameworkCore.Tests项目中,原因是数据过滤器是Ef Core特有的。在EntityFrameworkCore.Tests项目中,需要进行一些Mock数据的添加。

编码

首先创建Hierarchy.Tests项目,模块文件及配置按照Abp的编码风格进行。

创建一个MyTestHasHierarchyRepository类作为层级仓储的测试类:

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
public class MyTestHasHierarchyRepository : IHasHierarchyRepository<MyTestHierarchy>, ITransientDependency
{
public static readonly List<MyTestHierarchy> Nodes = new();

public Task DeleteNodeAsync(MyTestHierarchy node, CancellationToken cancellationToken = default)
{
Nodes.RemoveAll(node => node.Id == node.Id);
return Task.CompletedTask;
}

public Task<List<MyTestHierarchy>> GetAllChildrenAsync(MyTestHierarchy parent, bool isReadonly = true, CancellationToken cancellationToken = default)
{
if (parent == null)
{
return Task.FromResult(Nodes);
}

var children = Nodes.Where(node => node.HierarchyCode.StartsWith(parent.HierarchyCode) && node.Id != parent.Id);
return Task.FromResult(children.ToList());
}

public Task<string> GetLastChildCodeOrNullAsync(MyTestHierarchy parent, CancellationToken cancellationToken = default)
{
var children = Nodes.Where(node => node.Parent?.Id == parent?.Id);
var lastChild = children.OrderBy(child => child.HierarchyCode).LastOrDefault();
return Task.FromResult(lastChild?.HierarchyCode);
}

public Task SetHierarchyAsync(MyTestHierarchy node, MyTestHierarchy parent, string code, CancellationToken cancellationToken = default)
{
var nodeInRepository = Nodes.Where(n => n.Id == node.Id);
foreach (var n in nodeInRepository)
{
n.Parent = parent;
n.HierarchyCode = code;
};

return Task.CompletedTask;
}
}

其中的MyTestHierarchy为节点测试类,可简单创建为:

1
2
3
4
5
6
7
8
public class MyTestHierarchy : IHasHierarchy
{
public int Id { get; set; }

public string HierarchyCode { get; set; }

public MyTestHierarchy Parent { get; set; }
}

开始编写测试。

  1. 在测试文件中注入全局层级管理器:
1
2
3
4
5
6
private readonly IHierarchyCodeManager<IHasHierarchy> _globalManager;

public GlobalHierarchyCodeManager_Tests()
{
_globalManager = GetRequiredService<IHierarchyCodeManager<IHasHierarchy>>();
}
  1. 测试默认注册的IHierarchyCodeManager<IHasHierarchy>为我们需要的GlobalHierarchyCodeManager
1
2
3
4
5
[Fact]
public void Should_Register_Global_Code_Manager_As_Default()
{
ProxyHelper.UnProxy(_globalManager).ShouldBeOfType<GlobalHierarchyCodeManager>();
}
  1. 测试层级树中可包含不同节点类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Fact]
public async Task Should_Create_Hierarchy_Code_For_Nodes_Of_Different_Types()
{
var node1 = new MyTestHierarchy1();
var node2 = new MyTestHierarchy2();
var node3 = new MyTestHierarchy2();
var node4 = new MyTestHierarchy2();

node1.HierarchyCode = await _globalManager.CreateCodeAsync(node1, null);
node2.HierarchyCode = await _globalManager.CreateCodeAsync(node2, node1);
node3.HierarchyCode = await _globalManager.CreateCodeAsync(node3, node2);
node4.HierarchyCode = await _globalManager.CreateCodeAsync(node4, node1);

node2.HierarchyCode.ShouldStartWith(node1.HierarchyCode);
node3.HierarchyCode.ShouldStartWith(node2.HierarchyCode);
node4.HierarchyCode.ShouldStartWith(node1.HierarchyCode);
node4.HierarchyCode.ShouldNotStartWith(node2.HierarchyCode);
}

然后创建EntityFrameworkCore.Tests项目,模块文件及配置同样按照Abp的编码风格进行。

创建两个测试领域实体HierarchicalUserWorksheet,前者代表真实应用中我们的用户类,后者代表真实应用中具有获取权限的数据:

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
public class HierarchicalUser : Entity<Guid>, IHasHierarchy
{
[PreventUpdateIfDefault]
public string HierarchyCode { get; set; }

public string Name { get; set; }

private HierarchicalUser()
{

}

public HierarchicalUser(Guid id, string hierarchyCode, string name) : base(id)
{
HierarchyCode = hierarchyCode;
Name = name;
}
}

public class Worksheet : Entity<Guid>, IHasHierarchy
{
public string HierarchyCode { get; set; }

public string Title { get; set; }

private Worksheet()
{

}

public Worksheet(Guid id, string hierarchyCode, string title) : base(id)
{
HierarchyCode = hierarchyCode;
Title = title;
}
}

创建DbContextTestAppDbContext,并在Module文件中配置使用Sqlite数据库。

创建TestAppDataSeedContributor类进行初始数据Seeding:

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
public class TestAppDataSeedContributor : IDataSeedContributor, ITransientDependency
{
public static HierarchicalUser HierarchyUser1 { get; } = new(Guid.NewGuid(), string.Empty, "HierarchyUser1");
public static HierarchicalUser HierarchyUser2 { get; } = new(Guid.NewGuid(), "0001", "HierarchyUser2");
public static HierarchicalUser HierarchyUser3 { get; } = new(Guid.NewGuid(), "0001.0002.0003", "HierarchyUser3");
public static Worksheet Worksheet1 { get; } = new(Guid.NewGuid(), "0001.0002", "Worksheet1");
public static Worksheet Worksheet2 { get; } = new(Guid.NewGuid(), "0003.0004", "Worksheet2");

private readonly IHierarchicalUserRepository _hierarchicalUserRepository;
private readonly IWorksheetRepository _worksheetRepository;

public TestAppDataSeedContributor(
IHierarchicalUserRepository hierarchicalUserRepository,
IWorksheetRepository worksheetRepository)
{
_hierarchicalUserRepository = hierarchicalUserRepository;
_worksheetRepository = worksheetRepository;
}

public async Task SeedAsync(DataSeedContext context)
{
await _hierarchicalUserRepository.InsertAsync(HierarchyUser1);
await _hierarchicalUserRepository.InsertAsync(HierarchyUser2);
await _hierarchicalUserRepository.InsertAsync(HierarchyUser3);
await _worksheetRepository.InsertAsync(Worksheet1);
await _worksheetRepository.InsertAsync(Worksheet2);
}
}

在Module文件中,我们需要用HierarchicalUser类开启数据权限配置:

1
context.Services.AddHierarchicalUser<HierarchicalUser, HierarchicalUserRepository>();

在测试文件中,

  1. 验证一下权限数据过滤器已开启:

    1
    2
    3
    4
    5
    6
    [Fact]
    public void Should_Enabled_UseHierarchyUser_Option()
    {
    var option = GetRequiredService<IOptions<AbpXSecurityOptions>>().Value;
    option.UseHierarchyUser.ShouldBeTrue();
    }
  2. 为了便于测试,我们不引入真实的用户身份信息验证流程,直接在测试类中Mock需要的Claim:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    private ICurrentUser _fakeCurrentUser;

    ...

    private void SetFakeCurrentUserHierarchyCode(string hierarchyCode)
    {
    _fakeCurrentUser.FindClaim(AbpXClaimTypes.GlobalHierarchyCode).Returns(
    new Claim(AbpXClaimTypes.GlobalHierarchyCode, hierarchyCode));
    }
  3. 验证我们之前Seed的三个用户能正确地获取到他们权限范围内的数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    [Fact]
    public async Task Should_Get_Worksheets_For_Current_User()
    {
    await WithUnitOfWorkAsync(async () =>
    {
    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser1.HierarchyCode);

    var worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(2);

    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser2.HierarchyCode);

    worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(1);
    worksheets.Single().Id.ShouldBe(TestAppDataSeedContributor.Worksheet1.Id);

    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser3.HierarchyCode);

    worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(0);
    });
    }
  4. 验证未开启数据权限过滤时用户默认应能获取全部数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [Fact]
    public async Task Should_Get_All_Worksheets_When_Filter_Is_Disabled()
    {
    await WithUnitOfWorkAsync(async () =>
    {
    using (_hierarchyDataFilter.Disable())
    {
    SetFakeCurrentUserHierarchyCode(TestAppDataSeedContributor.HierarchyUser3.HierarchyCode);

    var worksheets = await _worksheetRepository.GetListAsync();
    worksheets.Count.ShouldBe(2);
    }
    });
    }

测试写完了,我们来跑一下:

测试结果

完美通过。

至此数据权限控制模块的开发与测试已全部完毕。