2024年7月

环境准备

:::info
实验目标:ServerA通过用户ServerB(已发送密钥和指定端口)
:::

主机 IP 身份
ServerA 192.168.10.201 SSH客户端
ServerB 192.168.10.202 SSH目标主机

在使用SSH登录远程主机时,指定的用户名是指远程主机上的用户。命令格式如下:

ssh username@remote_host

这里的
username
是指您打算用来登录远程目标主机的用户名,这个用户必须存在于远程目标主机的系统中,并且具有相应的访问权限。
remote_host
则代表您想要连接的远程服务器的IP地址或域名。
举例来说,如果您要使用名为
myuser
的用户账户登录IP地址为
192.168.0.100
的远程服务器,命令应写作:

ssh myuser@192.168.0.100

在这个命令中:
myuser
是您指定的用户名,对应的是远程服务器上已经存在的用户账户。
192.168.0.100
是远程主机的IP地址
执行此命令后,SSH客户端会尝试使用您本机当前用户的公钥(如果有已配置的密钥对认证)或提示您输入
myuser
账户在远程主机上的密码来进行身份验证。一旦身份验证成功,您将获得
myuser
用户在远程服务器上的 shell 终端会话。
总结来说,通过SSH登录远程主机时指定的用户名是指远程主机上的用户,而不是本机用户。本机用户的信息仅用于确定本地的SSH客户端配置(如私钥位置)以及可能的代理转发设置等,但实际登录远程服务器时的身份是由您提供的远程用户名决定的。

禁止root登录

root登录测试

[root@ServerA ~]# ssh -p 5000 root@ServerB
Activate the web console with: systemctl enable --now cockpit.socket

This system is not registered to Red Hat Insights. See https://cloud.redhat.com/
To register this system, run: insights-client --register

Last failed login: Thu Apr 18 03:59:14 EDT 2024 from 192.168.10.201 on ssh:notty
There was 1 failed login attempt since the last successful login.
Last login: Thu Apr 18 03:56:51 2024 from 192.168.10.201
[root@ServerB ~]# 注销

取消注释改为no

(如果不注销配置则无法生效)
image.png

root用户测试

[root@ServerA ~]# ssh -p 5000 root@ServerB
root@serverb's password: 
Permission denied, please try again.
root@serverb's password: 
Permission denied, please try again.
root@serverb's password: 
root@serverb: Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password).

image.png

允许禁止某用户登录

远程主机创建用户

[root@Serverb ~]# useradd zhangsan 
[root@Serverb ~]# passwd zhangsan 
更改用户 zhangsan 的密码 。
新的 密码:
无效的密码: 密码是一个回文
重新输入新的 密码:
passwd:所有的身份验证令牌已经成功更新。
[root@Serverb ~]# useradd lisi
[root@Serverb ~]# passwd lisi 
更改用户 lisi 的密码 。
新的 密码:
无效的密码: 密码是一个回文
重新输入新的 密码:
passwd:所有的身份验证令牌已经成功更新。

两个用户都可以远程登录

[root@servera ~]# ssh zhangsan@ServerB -p 5000
zhangsan@serverb's password: 
Last login: Fri Jul 12 14:33:34 2024 from servera
[zhangsan@serverb ~]$ exit
logout
Connection to serverb closed.
[root@servera ~]# ssh lisi@ServerB -p 5000
lisi@serverb's password: 
Last login: Fri Jul 12 11:24:26 2024 from servera
[lisi@serverb ~]$ exit
logout
Connection to serverb closed.
[root@servera ~]# 

底部添加参数允许zhangsan拒绝lisi

image.png

[root@Serverb ~]# vim /etc/ssh/sshd_config 
[root@Serverb ~]# systemctl restart sshd.service 
[root@Serverb ~]# tail -n 3 /etc/ssh/sshd_config 
#	ForceCommand cvs server
AllowUsers zhangsan
DenyUsers lisi
[root@Serverb ~]# 

测试登录

张三允许登录

[root@servera ~]# ssh zhangsan@ServerB -p 5000
zhangsan@serverb's password: 
Last login: Fri Jul 12 14:42:10 2024 from servera
[zhangsan@serverb ~]$ exit
logout
Connection to serverb closed.

李四禁止登录

[root@servera ~]# ssh lisi@ServerB -p 5000
lisi@serverb's password: 
Permission denied, please try again.
lisi@serverb's password: 
Permission denied, please try again.
lisi@serverb's password: 

Pam模块禁止用户

ServerB

只允许李四登录

[root@serverb ~]# echo "lisi" | sudo tee /etc/ssh/allowed_users
lisi
[root@serverb ~]# cat /etc/ssh/allowed_users
lisirverB ~]# chmod 644 /etc/ssh/allowed_users
[root@Serverb ~]# systemctl restart sshd.service 
[root@Serverb ~]# tail -n 1 /etc/ssh/sshd_config 
#	ForceCommand cvs server
[root@Serverb ~]# tail -n 1 /etc/pam.d/sshd 
auth required pam_listfile.so item=user sense=allow onerr=fail file=/etc/ssh/allowed_users

张三禁止登录

[root@servera ~]# ssh zhangsan@ServerB -p 5000
zhangsan@serverb's password: 
Permission denied, please try again.
zhangsan@serverb's password: 
Permission denied, please try again.
zhangsan@serverb's password: 
Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password).

李四可登录

[root@servera ~]# ssh lisi@ServerB -p 5000
lisi@serverb's password: 
Last login: Fri Jul 12 14:50:03 2024 from servera

Match User模块

ServerB

使用模块禁止李四

[root@ServerB ~]# vim /etc/ssh/sshd_config 
[root@ServerB ~]# tail -n 6 /etc/ssh/sshd_config 
#	AllowTcpForwarding no
#	PermitTTY no
#	ForceCommand cvs server
Match User lisi
    PermitTTY no
    ForceCommand /bin/false
[root@ServerB ~]# systemctl restart sshd.service 

这里,PermitTTY no禁止分配伪终端,ForceCommand /bin/false设置了一个无效的命令,确保用户无法成功登录。

ServerA

张三用户可登录

[root@servera ~]# ssh zhangsan@ServerB -p 5000
zhangsan@serverb's password: 
Last login: Fri Jul 12 15:01:09 2024 from servera
[zhangsan@serverb ~]$ exit
logout
Connection to serverb closed.

李四用户不可登录

[root@servera ~]# ssh lisi@ServerB -p 5000
lisi@serverb's password: 
PTY allocation request failed on channel 0
Connection to serverb closed.

指定其他用户免密登录

ServerA

Server通过指定端口将密钥发送到对应用户文件夹下

[root@servera ~]# ssh-copy-id -i .ssh/id_rsa.pub  -p 5000 zhangsan@ServerB
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: ".ssh/id_rsa.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
zhangsan@serverb's password: 

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh -p '5000' 'zhangsan@ServerB'"
and check to make sure that only the key(s) you wanted were added.

ServerB

[root@ServerB ~]# systemctl restart sshd.service 
[root@ServerB ~]# ll /home/zhangsan/.ssh/
总用量 4
-rw-------. 1 zhangsan zhangsan 580 4月  18 04:35 authorized_keys
[root@ServerB ~]# 

ServerA登录

[root@servera ~]# ssh zhangsan@ServerB -p 5000
Last login: Fri Jul 12 15:05:21 2024 from servera
[zhangsan@serverb ~]$ exit
logout
Connection to serverb closed.

禁止密码登录

PasswordAuthentication no

密码登录测试与免密测试

[root@servera ~]# ssh lisi@ServerB -p 5000
Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
[root@servera ~]# ssh zhangsan@ServerB -p 5000
Last login: Fri Jul 12 15:06:25 2024 from servera
[zhangsan@serverb ~]$ exit
logout
Connection to serverb closed.

指定用户和IP登录

AllowUsers zhangsan@192.168.10.201

拒绝登录

[root@localhost ~]# ifconfig ens160
ens160: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.10.128  netmask 255.255.255.0  broadcast 192.168.10.255
        inet6 fe80::3429:206b:def4:69f2  prefixlen 64  scopeid 0x20<link>
        ether 00:0c:29:be:62:ce  txqueuelen 1000  (Ethernet)
        RX packets 1453  bytes 1745198 (1.6 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 859  bytes 68106 (66.5 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

[root@localhost ~]# ssh zhangsan@192.168.10.202 -p 5000
zhangsan@192.168.10.202's password: 
Permission denied, please try again.
zhangsan@192.168.10.202's password: 
Permission denied, please try again.
zhangsan@192.168.10.202's password: 

允许地址登录

[root@servera ~]# ifconfig ens33
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.10.201  netmask 255.255.255.0  broadcast 192.168.10.255
        inet6 fe80::c086:7d71:44a8:c234  prefixlen 64  scopeid 0x20<link>
        ether 00:0c:29:9f:70:a6  txqueuelen 1000  (Ethernet)
        RX packets 811  bytes 153103 (149.5 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 993  bytes 212878 (207.8 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

[root@servera ~]# ssh zhangsan@ServerB -p 5000
Last failed login: Fri Jul 12 15:58:09 CST 2024 from 192.168.10.128 on ssh:notty
There were 3 failed login attempts since the last successful login.
Last login: Fri Jul 12 15:24:32 2024 from servera
[zhangsan@serverb ~]$ 

VMware Cloud Foundation
(简称 VCF)是 VMware 打造的一套用于 Software Defined Data Center(SDDC)软件定义数据中心的全栈云平台解决方案,将当前数据中心中的三大基础设施组件的虚拟化解决方案:计算(vSphere)、存储(vSAN)以及网络(NSX)进行高度整合并形成一个统一的由软件驱动的现代化数据中心。

利用 VMware 基于数据中心内三大虚拟化(vSphere、vSAN和NSX)解决方案的能力,用户可以借助 VCF 的
标准化

自动化
轻松建立一个由软件定义数据中心的基础架构平台。再搭配其他解决方案以形成一个完整的云计算平台,如 VMware Live Recovery(以前叫 Site Recovery Manager)实现数据中心的灾难恢复(DR),VMware Tanzu Kubernetes Grid(TKG)实现现代化的应用交付(Container),VMware Private AI Foundation with NVIDIA 实现企业内部的人工智能工作负载(AI/ML)等。

VMware Cloud Foundation 的优势是交付一套完整的 SDDC 私有云架构平台,但是并不局限于此,它还是一个可以作为公有云服务的通用平台,这可以极大的帮忙并满足用户迈向混合云平台架构的需求。比如,作为公有云中服务的 VMware Cloud on AWS 以及相关 VMware Cloud Provider(如 Alibaba Cloud、IBM Cloud 以及 Google Cloud 等)合作伙伴可以帮你实现这一愿望。

一、产品组件


VMware Cloud Foundation 产品功能如此强大当然离不开 VMware 在数据中心中潜心多年的各种虚拟化解决方案,通过将这些软件组件整合在一起形成一个整体的解决方案交付给用户进行使用。

这些组件包括:

核心组件

  • Cloud Builder
    ,执行 VCF 解决方案的自动化部署工具。
  • SDDC Manger
    ,执行整个 VCF 系统的软硬件生命周期管理。
  • vSphere(包括 ESXi 和 vCenter)
    ,虚拟化服务器CPU、存储和网络等计算资源。
  • vSAN
    ,聚合服务器本地磁盘并存储池化的 HCI 超融合解决方案。
  • NSX(Networking)
    ,通过软件来实现传统数据中心网络(Routing&Switching)设备相关功能。
  • Aria Suite(以前叫 vRealize)
    ,执行整个 VCF 云计算平台运维和监控管理的解决方案。

扩展组件

  • HCX
    ,将 KVM/Hyper-V 工作负载迁移 至 vSphere 环境或者从其他 vSphere 环境迁移至 VCF 环境。
  • NSX(Firewall)
    ,通过软件来实现传统数据中心安全设备相关功能,如分布式防火墙 Distributed Firewall(DFW)。
  • Data Service Manager(DSM)
    ,用于数据库(PostgreSQL 和 MySQL)的自动化部署和监控管理。
  • Avi Load Balancer(ALB)
    ,通过软件来实现传统数据中心中负载均衡器的功能。
  • VMware Live Recovery(VLR)
    ,针对数据中心灾难的数据备份和恢复的解决方案。
  • VMware Tanzu
    ,用于在 vSphere 环境中运行 Kubernetes 工作负载的解决方案。
  • VMware Private AI Foundation
    ,用于在 vSphere 环境中运行 AI/ML 工作负载的解决方案。

VMware Cloud Foundation 众多组件中大多都可以是独立的解决方案,而 SDDC Manager 是 VCF 解决方案中专有的管理组件,它是连接其他虚拟化产品并构成 SDDC 的关键核心。VMware Cloud Foundation 产品版本的发布通常意味着其组成组件产品的更新发布,通常 VMware 会在
VMware Cloud Foundation 产品发行文档
中发布包含其组件的 Bill of Materials(BOM)物料清单,或者可以访问 VMware
KB 52520
(Correlating VMware Cloud Foundation version with the versions of its constituent products)查看 VMware Cloud Foundation 产品及组成组件的版本和内部版本号的对应关系。

二、逻辑架构


VMware Cloud Foundation
中根据其组件所组成的功能和用途定义了
工作负载域
(Workload Domain),注意这里的“域”与 DNS 中的域没有关系,而是一种功能和用途的划分。其中包含两种类型的工作负载域,这在前期规划部署以及后期使用管理 VCF 时需要特别注重的地方。

  • 管理工作负载域(Management Workload Domain)

这是由 VMware Cloud Builder 工具在初始构建 VCF 时所部署的一种工作负载域,最初至少由 4 台 ESXi 主机所组成最基本的计算单元,通过 Cloud Builder 工具进行构建后,最终会形成一个由 vSphere + vSAN(必选) + NSX Manager + SDDC Manager + Aria Suite 等组件所组成的管理域,这个管理工作负载域主要用于放置 VCF 管理相关组件的虚拟机。一个 VCF 实例中通常只有一个管理域,而一个管理域中可以有一个或多个 vSphere 集群。

  • 虚拟基础架构工作负载域 (VI Workload Domain)

虚拟基础架构(Virtual Infrastructure)工作负载域主要用于放置真正的用于生产业务的虚拟机,通常这种工作负载域是在构建完管理域以后才进行配置的,最初至少由 3 台 ESXi 主机所组成最基本的计算单元,通过 SDDC Manager 进行配置后,最终会包含一个由 vSphere + vSAN(可选) + NSX Manager 组件所组成的 VI 域。一个 VCF 实例中可以有一个或多个 VI 域,而一个 VI 域中可以有一个或多个 vSphere 集群。

VMware Cloud Foundation 支持两种部署架构,一种是
标准架构
(Standard Architecture),另外一种是
整合架构
(Consolidated Architecture)。标准架构就是将管理域和 VI 域分开部署,管理域专门用于承载管理组件虚拟机,VI 域专门用于承载生产业务虚拟机;而整合架构就是将这两种域的用途合并到一块。VMware 推荐使用标准架构部署,因为标准架构部署符合 VCF 最佳实践,管理域和 VI 域分别使用单独的 vCenter Server 进行管理,对于可扩展性和可维护性来说要相对灵活一些;如果使用整合部署,则管理域和 VI 域使用同一个 vCenter Server 进行管理,需使用资源池进行两者工作负载的隔离。

三、构建流程


VMware Cloud Foundation 的构建是自动化的。第一步,先部署 VMware Cloud Builder 工具;第二步,使用 VMware Cloud Builder 部署管理域;第三步,使用 SDDC Manager 为真实的生产业务部署 VI 工作负载域。可以在 VMware Cloud Foundation 模式下使用 VMware Aria Suite 部署 Aria 相关云管产品,如用于运维监控的 Aria Operations 和用于自动化的 Aria Automation 等。

1)部署 Cloud Builder 工具。

VMware Cloud Builder 是用于初始构建 VMware Cloud Foundation 的自动化部署工具,默认情况下,当 VCF 发行新版本时会在 Broadcom 支持门户上以 OVA 文件的形式发布,由于该虚拟设备内置了 vCenter Server 和 NSX Manager 的 OVA 文件,所以它的大小大概有 30 GB左右,可以将它部署在 vSphere 或者 Workstation 环境上。

2)通过 Cloud Builder 构建管理域。

完成 Cloud Builder 的部署后,可以使用 Cloud Builder 工具以及随该设备一同发布的 Excel 格式的参数表来构建管理域,这个对于 VCF 初始构建的过程官方称为 Bring-up。构建管理域的前提需要准备至少 4 台 ESXi 主机用于承载管理域相关组件,如 vCenter Server、NSX Manager 以及 SDDC Manager 等。同时用于构建管理域所预定义的参数表需要提前对这 4 台主机进行规划,如 ESXi 主机用户名和密码、vSAN 以及 NSX 网络等,一个好的规划和设计是构建 VCF 成功的关键。当 Cloud Builder 构建好管理域之后,该工具便完成了使命,可以将其删除或者用于构建另一个 VCF 实例。

3)通过 SDDC Manager 构建 VI 域。

部署管理域成功后,可以使用 SDDC Manager 进行后续 VI 域的构建过程,与部署管理域一样,同样需要准备用于 VI 域的 ESXi 主机,并将其注册到 SDDC Manager 可置备的主机当中,然后你可以在 SDDC Manager 中使用 VI 域添加工作流完成 VI 域的构建。

4)通过 SDDC Manager 构建 Edge 集群。

通过 NSX 分段(Segment)和 Tier-1 网关可以实现本地数据中心的东西向流量及逻辑路由互通,如果想实现一个完整的 SDDC 数据中心解决方案,可以通过 SDDC Manager 创建 Tier-0 网关以及部署 NSX Edge 集群实现南北向流量并与外部网络互通。

注意,在 VMware Cloud Foundation 构建过程中,构建管理工作负载域的时候只能将 vSAN 作为主要存储来部署相关管理组件,后期可以添加基于 IP/FC 网络的补充存储来使用,在构建 VI 工作负载域的时候所使用的存储没有特殊要求。

在 VMware Cloud Foundation 部署完以后,使用 SDDC Manager UI 即可对 VCF 当中包含的相关 BOM 组件进行统一的生命周期管理。虽然,SDDC Manager 的确可以做很多工作,但是对于像虚拟机的使用管理可能还是得使用 vSphere Client UI 界面,对于 NSX 的某些管理操作可能还是得需要登录到 NSX Manager UI 界面。

四、VCF 海报


VMware Cloud Foundation 5.x 海报,展示了 VMware Cloud Foundation 5 Private Cloud Platform 中的精彩功能,可以访问原址
VMware Cloud Foundation 5.x Posters
(https://core.vmware.com/vcf-5-private-cloud-poster)或者
百度网盘
(https://pan.baidu.com/s/1R4VQVVdxb2qfx5DNQVWuIg?pwd=jyj9)下载源图。

后续会继续发布 VCF 文章,敬请关注~

高阶函数是指接受其它函数作为参数,或者返回其它函数的函数。Swift 提供了许多内置的高阶函数,这些函数在处理集合类型数据(如数组、集合等)时尤其有用。常见的高阶函数包括
map

filter

reduce

flatMap

compactMap

一、常用高阶函数

1.
map

map
函数会对集合中的每个元素应用一个相同的操作,并将结果聚合成一个新的集合。

示例:

假设我们有一个包含整数的数组,并希望将每个整数乘以2。

let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map { $0 * 2 }
print(doubled)  // 输出: [2, 4, 6, 8, 10]

2.
filter

filter
函数会对集合中的每个元素进行条件判断,并返回符合条件的元素构成的新的集合。

示例:

我们有一个数组,需要过滤出所有的偶数。

let numbers = [1, 2, 3, 4, 5]
let evens = numbers.filter { $0 % 2 == 0 }
print(evens)  // 输出: [2, 4]

3.
reduce

reduce
函数会将集合中的元素组合成一个值,通过应用一个累计的操作。它需要一个初始值和一个合并操作。

示例:

我们有一个数组,需要计算所有元素的总和。

let numbers = [1, 2, 3, 4, 5]
let sum = numbers.reduce(0) { $0 + $1 }
print(sum)  // 输出: 15

使用闭包语法可以简洁一点:

let sum = numbers.reduce(0, +)
print(sum)  // 输出: 15

4.
flatMap

flatMap
会对多维集合进行扁平化操作,并将子集合中每一个元素应用特定的操作,然后返回一个新的集合。

示例:

我们有一个由数组组成的数组,需要将其展平成一个单一的数组。

let arrayOfArrays = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
let flattened = arrayOfArrays.flatMap { $0 }
print(flattened)  // 输出: [1, 2, 3, 4, 5, 6, 7, 8, 9]

5.
compactMap

compactMap

map
相似,但它会移除所有结果中的
nil
值。通常用于处理返回
Optional
的操作。

示例:

我们有一个字符串数组,希望将其转换为整数,但其中有些值无法转换。

let strings = ["1", "2", "three", "4", "five"]
let numbers = strings.compactMap { Int($0) }
print(numbers)  // 输出: [1, 2, 4]

6. 自定义高阶函数

除了 Swift 提供的这些高阶函数,你也可以根据需要定义自己的高阶函数。

示例:

我们定义一个高阶函数,该函数接受一个过滤条件并返回符合条件的数组:

func customFilter<T>(array: [T], condition: (T) -> Bool) -> [T] {
    var result = [T]()
    for element in array {
        if condition(element) {
            result.append(element)
        }
    }
    return result
}

let numbers = [1, 2, 3, 4, 5]
let evens = customFilter(array: numbers) { $0 % 2 == 0 }
print(evens)  // 输出: [2, 4]

二、其他高阶函数

除了已经介绍的
map

filter

reduce

flatMap

compactMap
之外,Swift 还提供了其他一些内置的高阶函数。

1.
forEach

forEach
函数会对集合中的每一个元素执行指定的操作,但不会返回结果。它通常用作遍历集合的一个简洁替代。

示例:

let numbers = [1, 2, 3, 4, 5]
numbers.forEach { number in
    print(number)
}

2.
sorted

sort

sorted
函数会返回一个排序后的新数组,而
sort
方法则会原地排序数组。

示例:

let numbers = [5, 3, 1, 4, 2]
let sortedNumbers = numbers.sorted()
print(sortedNumbers)  // 输出: [1, 2, 3, 4, 5]

使用自定义排序规则:

let sortedDescending = numbers.sorted { $0 > $1 }
print(sortedDescending)  // 输出: [5, 4, 3, 2, 1]

原地排序:

var numbers = [5, 3, 1, 4, 2]
numbers.sort()
print(numbers)  // 输出: [1, 2, 3, 4, 5]

3.
contains

contains
函数用于检查集合中是否包含某个元素。

示例:

let numbers = [1, 2, 3, 4, 5]
let containsThree = numbers.contains(3)
print(containsThree)  // 输出: true

或者使用自定义条件:

let hasEvenNumber = numbers.contains { $0 % 2 == 0 }
print(hasEvenNumber)  // 输出: true

4.
first(where:)

last(where:)

first(where:)
函数会返回满足条件的第一个元素,
last(where:)
会返回满足条件的最后一个元素。

示例:

let numbers = [1, 2, 3, 4, 5]
if let firstEven = numbers.first(where: { $0 % 2 == 0 }) {
    print(firstEven)  // 输出: 2
}

if let lastEven = numbers.last(where: { $0 % 2 == 0 }) {
    print(lastEven)  // 输出: 4
}

5.
allSatisfy

allSatisfy
函数会检查集合中的所有元素是否都满足指定的条件。

示例:

let numbers = [2, 4, 6, 8, 10]
let allEven = numbers.allSatisfy { $0 % 2 == 0 }
print(allEven)  // 输出: true

6.
dropFirst

dropLast

dropFirst
函数会移除集合的第一个元素,
dropLast
函数则会移除集合的最后一个元素。

示例:

let numbers = [1, 2, 3, 4, 5]
let withoutFirst = numbers.dropFirst()
print(withoutFirst)  // 输出: [2, 3, 4, 5]

let withoutLast = numbers.dropLast()
print(withoutLast)  // 输出: [1, 2, 3, 4]

7.
prefix

suffix

prefix
函数会返回集合的前几个元素,
suffix
函数会返回集合的最后几个元素。

示例:

let numbers = [1, 2, 3, 4, 5]
let firstThree = numbers.prefix(3)
print(firstThree)  // 输出: [1, 2, 3]

let lastTwo = numbers.suffix(2)
print(lastTwo)  // 输出: [4, 5]

8.
zip

zip
函数会合并两个集合,依次创建对应的元素对,形成一个新的序列。

示例:

let names = ["Alice", "Bob", "Charlie"]
let ages = [25, 30, 35]
let combined = zip(names, ages)

for (name, age) in combined {
    print("\(name) is \(age) years old")
}
// 输出:
// Alice is 25 years old
// Bob is 30 years old
// Charlie is 35 years old

9.
reduce(into:)

reduce(into:)
可以用来将集合的元素聚合成一个新集合,避免像
reduce
那样频繁地创建新值,从而提高性能。

示例:

let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.reduce(into: [Int]()) { result, number in
    result.append(number * 2)
}
print(doubled)  // 输出: [2, 4, 6, 8, 10]

总结

Swift 提供了丰富的内置高阶函数,这些函数极大地简化了对集合数据的处理,使代码更加简洁和功能性更强。通过灵活运用这些高阶函数,可以减少代码中的循环和条件判断,使代码更具可读性和维护性。了解并掌握这些高阶函数,可以帮助你编写更加简洁、高效和优雅的 Swift 代码。

41.监听连线拖拽结束后的事件

可以使用
LinkingTool
工具来监听连线拖拽结束后的事件。具体步骤如下:

  1. 创建一个
    LinkingTool
    实例,并将其设置为图表的默认工具:
myDiagram.toolManager.linkingTool = new go.LinkingTool();
  1. 监听
    LinkingTool

    doCancel()

    doActivate()
    方法,以便在连线拖拽结束后执行相应的操作:
myDiagram.toolManager.linkingTool.doCancel = function () {
  // 会在用户取消连线拖拽时被调用
  console.log("Linking cancelled");
  go.LinkingTool.prototype.doCancel.call(this);
};

myDiagram.toolManager.linkingTool.doActivate = function () {
  // 会在用户完成连线拖拽时被调用
  console.log("Linking completed");
  go.LinkingTool.prototype.doActivate.call(this);
};

42.监听画布的修改事件

在 gojs 每个案例当中都会有这样一段代码,
myDiagram.addDiagramListener('Modified', function() {...})
方法是用来监听画布的修改事件,当画布被修改时,会执行对应的回调函数。 但是这个方法
不适用于持续监听
,也就是说 每次修改后保存之后,在修改才能触发

// when the document is modified, add a "*" to the title and enable the "Save" button
myDiagram.addDiagramListener("Modified", function (e) {
  var button = document.getElementById("SaveButton");
  if (button) button.disabled = !myDiagram.isModified;
  var idx = document.title.indexOf("*");
  console.log(111);
  if (myDiagram.isModified) {
    if (idx < 0) document.title += "*";
  } else {
    if (idx >= 0) document.title = document.title.substr(0, idx);
  }
});

想要监听画布的每一次变化
,并执行对应的 JSON 变化,该事件会在每次模型被修改时触发,可以在回调函数中获取到最新的 JSON 数据。如果修改很频繁并且后续操作也较为复杂时,建议将修改函数加一层防抖

myDiagram.addChangedListener((e) => {
  // 执行对应的 JSON 变化操作
  var jsonData = myDiagram.model.toJson();
});

43.监听节点被 del 删除后回调事件(用于实现调用接口做一些真实的删除操作)

删除节点的事件名称为
SelectionDeleting
。可以使用以下代码来监听删除节点的事件并调用接口:

myDiagram.addDiagramListener("SelectionDeleting", (e) => {
  const deletedObj = e.subject.first();
  const deleteKey = deletedObj.part.data.key;
});

44.监听节点鼠标移入移出事件,hover 后显示特定元素

通过 GoJS 的事件处理机制来实现树节点鼠标悬浮时显示添加节点按钮的功能。具体实现步骤如下:

  1. 在节点模板中添加一个按钮元素,用于显示添加节点按钮。
  2. 在节点模板中添加鼠标悬浮事件处理函数,当鼠标悬浮在节点上时,显示添加节点按钮。
  3. 在节点模板中添加鼠标移出事件处理函数,当鼠标移出节点时,隐藏添加节点按钮。
  4. 在添加节点按钮的模板中添加鼠标单击事件处理函数,用于添加新节点。
myDiagram.nodeTemplate = $(
  go.Node,
  "Auto",
  {
    mouseEnter: function (e, node) {
      // 鼠标悬浮在节点上时,显示添加节点按钮
      var addButton = node.findObject("addButton");
      if (addButton) addButton.visible = true;
    },
    mouseLeave: function (e, node) {
      // 鼠标移出节点时,隐藏添加节点按钮
      var addButton = node.findObject("addButton");
      if (addButton) addButton.visible = false;
    },
  },
  $(go.Shape, "RoundedRectangle", { fill: "white" }),
  $(go.TextBlock, { margin: 8 }, new go.Binding("text", "name")),
  $(
    go.Panel,
    "Horizontal",
    { alignment: go.Spot.BottomRight, margin: 4 },
    $(
      "Button",
      { name: "addButton", visible: false, click: (e, obj) => {} },
      $(go.TextBlock, "+")
    )
  )
);

45.监听树图实现鼠标点击节点本身展开或收起子节点的功能,而不是点击另外的按钮

  // 实现鼠标点击节点本身展开或收起子节点的功能
  myDiagram.addDiagramListener('ObjectSingleClicked', (e, obj) {
    var part = e.subject.part
    if (part instanceof go.Node) {
      if (part.isTreeExpanded) {
        part.collapseTree()
      } else {
        part.expandTree()
      }
    }
  })

46.监听文本块编辑结束后回调事件(用于实现调用接口做一些真实的编辑修改操作)

可以使用 DiagramEvent 类的
TextEdited
事件来监听编辑文字结束的回调函数。该事件在文本编辑器完成编辑并关闭时触发.

myDiagram.addDiagramListener("TextEdited", (e) => {
  const editedText = e.subject.text;
  const key = e.subject.part.data.key;
  console.log("编辑后的内容: " + editedText);
  console.log("节点key:", key);
});

47.文本编辑结束后,实现回车后取消编辑状态

默认情况下,在用户完成文本块编辑后。需要鼠标移出后点击其他区域,才能取消当前正在编辑的文本块的编辑状态,并且在默认情况下,按下
回车enter
键,视为换行。

但大部分用户的习惯性操作为按下
回车enter
键,即已经完成编辑操作,而不是换行,因此需要改一个属性。
是否可以多行-isMultiline
,属性值为 false 时,按下回车完成编辑操作.

$(
  go.TextBlock,
  {
    editable: true, // 是否 可编辑
    isMultiline: false, // 是否 可多行,false
  },
  new go.Binding("text", "name")
);

用到的属性/方法说明:

1. go.LinkingTool.prototype.doActivate

是 GoJS 库中的一个方法,用于在用户开始使用链接工具时激活该工具。当用户点击链接工具按钮或按下链接工具快捷键时,该方法将被调用。

2. reshapable 和 resegmentable 属性

  • reshapable
    属性:表示节点是否可以被重新调整形状。设置为
    true
    ,则节点可以通过拖动边缘或角落来改变其形状。设置为
    false
    ,则节点的形状将保持不变。

  • resegmentable
    属性:表示节点的连线是否可以被重新分段。设置为
    true
    ,则节点的连线可以通过拖动中间的点来添加或删除分段。设置为
    false
    ,则节点的连线将保持不变。

3. new go.Binding("text", "name").makeTwoWay()中 makeTwoWay 的作用

makeTwoWay
方法用于将绑定设置为双向绑定。在 GoJS 中,绑定是指将数据模型中的属性与图形元素的属性相互关联,以便在数据模型中更改属性时,图形元素的属性也会相应地更改。

当使用
makeTwoWay
方法时,绑定将变为双向绑定,这意味着当图形元素的属性更改时,数据模型中的属性也会相应地更改。这使得数据模型和图形元素之间的同步更加方便和高效。

4. Point 基本用法

Point
是 GoJS 中的一个类,用于表示一个二维平面上的点。它的定义在 GoJS 的 API 文档中可以找到。

在 GoJS 中,可以使用
new go.Point(x, y)
的方式创建一个
Point
对象,其中
x

y
分别表示点的横坐标和纵坐标。例如:

var point = new go.Point(100, 200);

也可以使用
Point.parse(str)
方法将一个字符串解析为一个
Point
对象。
str
是一个字符串,表示一个点的坐标。
Point.parse(str)
方法将这个字符串解析为一个
Point
对象,并将其赋值给
point
变量。例如:

var str = "100 200";
var point = go.Point.parse(str);

5. draggingTool.gridSnapCellSize 作用

draggingTool.gridSnapCellSize
是 gojs 中拖拽工具的属性之一,它定义了拖拽时网格捕捉的单元格大小。当设置了这个属性后,拖拽的元素会自动对齐到网格的单元格边界上,从而使得布局更加整齐美观。例如,如果将 draggingTool.gridSnapCellSize 设置为 20,则拖拽的元素会自动对齐到 20 像素的边界上。这个属性的默认值为 null,表示不进行网格捕捉。

myDiagram = $(go.Diagram, "myDiagramDiv", {
  "draggingTool.gridSnapCellSize": new go.Size(1, 5),
});

6.initialPosition 初始坐标

gojs 生成绘图时,默认情况下是将图形放置在画布的中心位置。因为 gojs 的默认布局是居中布局。但是可以通过设置 Diagram 的
initialPosition
属性来改变它的位置。如果想让绘图从
左上角
开始,可以:

var myDiagram = $(go.Diagram, "myDiagramDiv", {
  initialPosition: new go.Point(0, 0), // 初始坐标位置
});

7.修改按钮默认样式

$(
  "Button",
  {
    width: 15,
    height: 15,
    "ButtonBorder.fill": "green", // 改掉默认填充色
    _buttonFillOver: "red", // 鼠标悬浮填充色
    //   _buttonStrokeOver: "#000", // 鼠标悬浮边框色
  },
  $(go.TextBlock, "按钮文字")
);

8.放大缩小,还原重做

// 放大
myDiagram.scale += 0.1;
// 缩小
myDiagram.scale -= 0.1;
// 还原
myDiagram.commandHandler.undo();
// 重做
myDiagram.commandHandler.redo();

9.根据 key 值查找节点元素,查找元素内的零件

// 根据 key 值查找节点元素
const node = diagram.findNodeForKey("key值");
// 查找元素内的零件: 查找元素name:TEXT的零件,字体颜色改为红色
node.findObject("TEXT").stroke = "red";

10.toolManager.hoverDelay

toolManager.hoverDelay
属性用于设置鼠标指针悬停在图表元素上时将触发悬停事件的延迟时间

鼠标悬停在图形元素上才会触发 hover 事件。默认值为 300 毫秒。如果将其设置为 200,表示鼠标悬停在图形元素上 200 毫秒后才会触发 hover 事件。

myDiagram = $(go.Diagram, "myDiagramDiv", {
  "toolManager.hoverDelay": 200, // 延时
});

我一般会用在 toolTip 的显示时间上,鼠标悬浮显示 toolTip,默认延时时间有点长,可以通过以上属性,更改为合适的时间,
toolTip
的基本用法:

$(
  go.TextBlock,
  {
    width: 100,
    maxLines: 1,
    overflow: go.TextBlock.OverflowEllipsis,
  },
  new go.Binding("text", "name").makeTwoWay(),
  {
    toolTip: $("ToolTip", $(go.TextBlock, new go.Binding("text", "name"))),
  }
);

前言

数据库并发,数据审计和软删除一直是数据持久化方面的经典问题。早些时候,这些工作需要手写复杂的SQL或者通过存储过程和触发器实现。手写复杂SQL对软件可维护性构成了相当大的挑战,随着SQL字数的变多,用到的嵌套和复杂语法增加,可读性和可维护性的难度是几何级暴涨。因此如何在实现功能的同时控制这些SQL的复杂度是一个很有价值的问题。而且这个问题同时涉及应用软件和数据库两个相对独立的体系,平行共管也是产生混乱的一大因素。

EF Core作为 .NET平台的高级ORM框架,可以托管和数据库的交互,同时提供了大量扩展点方便自定义。以此为基点把对数据库的操作托管后便可以解决平行共管所产生的混乱,利用LINQ则可以最大程度上降低软件代码的维护难度。

由于项目需要,笔者先后开发并发布了通用的
基于EF Core存储的国际化服务

基于EF Core存储的Serilog持久化服务
,不过这两个功能包并没有深度利用EF Core,虽然主要是因为没什么必要。但是项目还需要提供常用的数据审计和软删除功能,因此对EF Core进行了一些更深入的研究。

起初有考虑过是否使用现成的ABP框架来处理这些功能,但是在其他项目的使用体验来说并不算好,其中充斥着大量上下文依赖的功能,而且这些依赖信息能轻易藏到和最终业务代码相距十万八千里的地方(特别是代码还是别人写的时候),然后在不经意间给你一个大惊喜。对于以代码正交性、非误导性,纯函数化为追求的一介码农(看过我发布的那两个功能包的朋友应该有感觉,一个功能笔者也要根据用途划分为不同的包,确保解决方案中的各个项目都能按需引用,不会残留无用的代码),实在是喜欢不起来ABP这种全家桶。

鉴于项目规模不大,笔者决定针对这些需求做一个专用功能,目标是尽可能减少依赖,方便将来复用到其他项目,降低和其他功能功能冲突的风险。现在笔者将用一系列博客做成果展示。由于这些功能没有经过大范围测试,不确定是否存在未知缺陷,因此暂不打包发布。

新书宣传

有关新书的更多介绍欢迎查看
《C#与.NET6 开发从入门到实践》上市,作者亲自来打广告了!
image

正文

由于这些功能设计的代码量和知识点较多,为控制篇幅,本文介绍数据审计和乐观并发功能。

EF Core 3.0新增了侦听器功能,允许在实际执行操作之前或之后插入自定义操作,利用这个功能可以实现数据审计的自动化。为此需要做些前期准备。

审计实体接口

乐观并发接口

/// <summary>
/// 乐观并发接口
/// </summary>
public interface IOptimisticConcurrencySupported
{
    /// <summary>
    /// 行版本,乐观并发锁
    /// </summary>
    [ConcurrencyCheck]
    string? ConcurrencyStamp { get; set; }
}

SqlServer数据库支持自动的行版本功能,但是大多数其他数据库并不支持,因此选用兼容性更好的方案。Identity Core为了兼容性也不用行版本实现乐观并发。

时间审计接口

/// <summary>
/// 创建和最近更新时间审计的合成接口
/// </summary>
public interface IFullyTimeAuditable : ICreationTimeAuditable, ILastUpdateTimeAuditable;

/// <summary>
/// 创建时间审计接口
/// </summary>
public interface ICreationTimeAuditable
{
    /// <summary>
    /// 创建时间标记
    /// </summary>
    DateTimeOffset? CreatedAt { get; set; }
}

/// <summary>
/// 最近更新时间审计接口
/// </summary>
public interface ILastUpdateTimeAuditable
{
    /// <summary>
    /// 最近更新时间标记
    /// </summary>
    DateTimeOffset? LastUpdatedAt { get; set; }
}

操作人审计接口

/// <summary>
/// 创建和最近更新用户审计的合成接口
/// </summary>
/// <typeparam name="TIdentityKey">用户Id类型</typeparam>
public interface IFullyOperatorAuditable<TIdentityKey>
    : ICreationUserAuditable<TIdentityKey>
    , ILastUpdateUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>;

/// <summary>
/// 包括导航的创建和最近更新用户审计的合成接口
/// </summary>
/// <typeparam name="TIdentityKey">用户Id类型</typeparam>
/// <typeparam name="TUser">用户类型</typeparam>
public interface IFullyOperatorAuditable<TIdentityKey, TUser>
    : ICreationUserAuditable<TIdentityKey, TUser>
    , ILastUpdateUserAuditable<TIdentityKey, TUser>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
    where TUser : class;

/// <summary>
/// 创建用户审计接口
/// </summary>
/// <typeparam name="TIdentityKey">用户Id类型</typeparam>
public interface ICreationUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    /// <summary>
    /// 创建用户Id
    /// </summary>
    TIdentityKey? CreatedById { get; set; }
}

/// <summary>
/// 包括导航的创建用户审计接口
/// </summary>
/// <typeparam name="TUser">用户类型</typeparam>
/// <inheritdoc />
public interface ICreationUserAuditable<TIdentityKey, TUser> : ICreationUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
    where TUser : class
{
    /// <summary>
    /// 创建用户
    /// </summary>
    TUser? CreatedBy { get; set; }
}

/// <summary>
/// 最近更新用户审计接口
/// </summary>
/// <typeparam name="TIdentityKey">用户Id类型</typeparam>
public interface ILastUpdateUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    /// <summary>
    /// 最近更新用户Id
    /// </summary>
    TIdentityKey? LastUpdatedById { get; set; }
}

/// <summary>
/// 包括导航的最近更新用户审计接口
/// </summary>
/// <typeparam name="TUser">用户类型</typeparam>
/// <inheritdoc />
public interface ILastUpdateUserAuditable<TIdentityKey, TUser> : ILastUpdateUserAuditable<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
    where TUser : class
{
    /// <summary>
    /// 最近更新用户
    /// </summary>
    TUser? LastUpdatedBy { get; set; }
}

使用接口方便和已有代码集成。带导航的操作人接口使用结构体Id方便准确控制外键可空性。

需要的辅助方法

public static class RuntimeTypeExtensions
{
    /// <summary>
    /// 判断 <paramref name="type"/> 指定的类型是否派生自 <typeparamref name="T"/> 类型,或实现了 <typeparamref name="T"/> 接口
    /// </summary>
    /// <typeparam name="T">要匹配的类型</typeparam>
    /// <param name="type">需要测试的类型</param>
    /// <returns>如果 <paramref name="type"/> 指定的类型派生自 <typeparamref name="T"/> 类型,或实现了 <typeparamref name="T"/> 接口,则返回 <see langword="true"/>,否则返回 <see langword="false"/>。</returns>
    public static bool IsDerivedFrom<T>(this Type type)
    {
        return IsDerivedFrom(type, typeof(T));
    }

    /// <summary>
    /// 判断 <paramref name="type"/> 指定的类型是否继承自 <paramref name="pattern"/> 指定的类型,或实现了 <paramref name="pattern"/> 指定的接口
    /// <para>支持开放式泛型,如<see cref="List{T}" /></para>
    /// </summary>
    /// <param name="type">需要测试的类型</param>
    /// <param name="pattern">要匹配的类型,如 <c>typeof(int)</c>,<c>typeof(IEnumerable)</c>,<c>typeof(List&lt;&gt;)</c>,<c>typeof(List&lt;int&gt;)</c>,<c>typeof(IDictionary&lt;,&gt;)</c></param>
    /// <returns>如果 <paramref name="type"/> 指定的类型继承自 <paramref name="pattern"/> 指定的类型,或实现了 <paramref name="pattern"/> 指定的接口,则返回 <see langword="true"/>,否则返回 <see langword="false"/>。</returns>
    public static bool IsDerivedFrom(this Type type, Type pattern)
    {
        ArgumentNullException.ThrowIfNull(type);
        ArgumentNullException.ThrowIfNull(pattern);

        // 测试非泛型类型(如ArrayList)或确定类型参数的泛型类型(如List<int>,类型参数T已经确定为 int)
        if (type.IsSubclassOf(pattern)) return true;

        // 测试非泛型接口(如IEnumerable)或确定类型参数的泛型接口(如IEnumerable<int>,类型参数T已经确定为 int)
        if (pattern.IsAssignableFrom(type)) return true;

        // 测试泛型接口(如IEnumerable<>,IDictionary<,>,未知类型参数,留空)
        var isTheRawGenericType = type.GetInterfaces().Any(IsTheRawGenericType);
        if (isTheRawGenericType) return true;

        // 测试泛型类型(如List<>,Dictionary<,>,未知类型参数,留空)
        while (type != null && type != typeof(object))
        {
            isTheRawGenericType = IsTheRawGenericType(type);
            if (isTheRawGenericType) return true;
            type = type.BaseType!;
        }

        // 没有找到任何匹配的接口或类型。
        return false;

        // 测试某个类型是否是指定的原始接口。
        bool IsTheRawGenericType(Type test)
            => pattern == (test.IsGenericType ? test.GetGenericTypeDefinition() : test);
    }
}

/// <summary>
/// 实体配置相关泛型方法生成扩展
/// </summary>
internal static class EntityConfigurationMethodsHelper
{
    private const BindingFlags _bindingFlags = BindingFlags.Public | BindingFlags.Static;
    private static readonly ImmutableArray<MethodInfo> _configurationMethods;
    private static readonly MethodInfo _genericEntityTypeBuilderGetterMethod;

    static EntityConfigurationMethodsHelper()
    {
        _configurationMethods =
            [
                .. typeof(EntityModelBuilderExtensions).GetMethods(_bindingFlags),
                .. typeof(OperationUserAuditableEntityModelBuilderExtensions).GetMethods(_bindingFlags),
                .. typeof(TimeAuditableEntityModelBuilderExtensions).GetMethods(_bindingFlags),
                .. typeof(TreeEntityModelBuilderExtensions).GetMethods(_bindingFlags),
            ];

        _genericEntityTypeBuilderGetterMethod = typeof(ModelBuilder)
            .GetMethods(BindingFlags.Public | BindingFlags.Instance)
            .Where(static m => m.Name is nameof(ModelBuilder.Entity))
            .Where(static m => m.IsGenericMethod)
            .Where(static m => m.GetParameters().Length is 0)
            .Single();
    }

    /// <summary>
    /// 获取泛型实体类型配置扩展方法
    /// </summary>
    /// <param name="name">方法名</param>
    /// <param name="ParametersCount">参数数量</param>
    /// <returns>已生成的封闭式泛型配置扩展方法</returns>
    internal static MethodInfo GetEntityTypeConfigurationMethod(string name, int ParametersCount, params Type[] typeParameterTypes)
    {
        ArgumentNullException.ThrowIfNull(name);
        ArgumentNullException.ThrowIfNull(typeParameterTypes);

        return _configurationMethods
            .Where(m => m.Name == name)
            .Where(m => m.GetParameters().Length == ParametersCount)
            .Where(static m => m.IsGenericMethod)
            .Where(m => m.GetGenericArguments().Length == typeParameterTypes.Length)
            .Single()
            .MakeGenericMethod(typeParameterTypes);

    }

    /// <summary>
    /// 获取泛型实体类型构造器
    /// </summary>
    /// <param name="entity">实体类型</param>
    /// <returns></returns>
    internal static MethodInfo GetEntityTypeBuilderMethod(IMutableEntityType entity)
    {
        ArgumentNullException.ThrowIfNull(entity);

        // 动态生成泛型方法使配置逻辑拥有唯一的定义位置,避免发生不必要的问题
        return _genericEntityTypeBuilderGetterMethod.MakeGenericMethod(entity.ClrType);
    }
}

/// <summary>
/// 指示实体配置适用于何种数据库提供程序
/// </summary>
/// <param name="ProviderName"></param>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class DatabaseProviderAttribute(string ProviderName) : Attribute
{
    /// <summary>
    /// 提供程序名称
    /// </summary>
    public string ProviderName { get; } = ProviderName;
}

把实体配置扩展方法缓存起来方便之后批量调用,因为EF Core的泛型和非泛型实体构造器无法直接转换,只能通过反射动态生成泛型方法复用单体配置扩展。这样能保证配置代码只有唯一一份,避免重复代码导致维护时出现疏漏。

实体模型配置扩展

乐观并发扩展

/// <summary>
/// 配置乐观并发实体的并发检查字段
/// </summary>
/// <typeparam name="TEntity">实体类型</typeparam>
/// <param name="builder">实体类型构造器</param>
/// <returns>实体属性构造器</returns>
public static PropertyBuilder<string> ConfigureForIOptimisticConcurrencySupported<TEntity>(
    this EntityTypeBuilder<TEntity> builder)
    where TEntity : class, IOptimisticConcurrencySupported
{
    ArgumentNullException.ThrowIfNull(builder);

    return builder.Property(e => e.ConcurrencyStamp!).IsConcurrencyToken();
}

/// <summary>
/// 批量配置乐观并发实体的并发检查字段
/// </summary>
/// <param name="modelBuilder">模型构造器</param>
/// <returns>模型构造器</returns>
public static ModelBuilder ConfigureForIOptimisticConcurrencySupported(this ModelBuilder modelBuilder)
{
    ArgumentNullException.ThrowIfNull(modelBuilder);

    foreach (var entity
        in modelBuilder.Model.GetEntityTypes()
            .Where(static e => !e.HasSharedClrType)
            .Where(static e => e.ClrType.IsDerivedFrom<IOptimisticConcurrencySupported>()))
    {
        var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
        var optimisticConcurrencySupportedMethod = GetEntityTypeConfigurationMethod(
            nameof(ConfigureForIOptimisticConcurrencySupported),
            1,
            entity.ClrType);

        optimisticConcurrencySupportedMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
    }

    return modelBuilder;
}

时间审计扩展

/// <summary>
/// 实体时间审计配置扩展
/// </summary>
public static class TimeAuditableEntityModelBuilderExtensions
{
    /// <summary>
    /// 配置创建时间审计
    /// </summary>
    /// <typeparam name="TEntity">实体类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    /// <param name="defaultValueSql">默认值Sql</param>
    /// <returns>实体类型构造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForCreationTimeAuditable<TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITimeAuditableDefaultValueSql defaultValueSql)
        where TEntity : class, ICreationTimeAuditable
    {
        builder.Property(e => e.CreatedAt)
            .IsRequired()
            .HasDefaultValueSql(defaultValueSql.Sql);

        return builder;
    }

    /// <summary>
    /// 批量配置创建时间审计
    /// </summary>
    /// <param name="modelBuilder">模型构造器</param>
    /// <returns>模型构造器</returns>
    public static ModelBuilder ConfigureForCreationTimeAuditable(
        this ModelBuilder modelBuilder,
        ITimeAuditableDefaultValueSql defaultValueSql)
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ICreationTimeAuditable>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var creationTimeAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForCreationTimeAuditable),
                2,
                entity.ClrType);

            creationTimeAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), defaultValueSql]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 配置最近更新时间审计
    /// </summary>
    /// <typeparam name="TEntity">实体类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    /// <param name="defaultValueSql">默认值Sql</param>
    /// <returns>实体类型构造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForLastUpdateTimeAuditable<TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITimeAuditableDefaultValueSql defaultValueSql)
        where TEntity : class, ILastUpdateTimeAuditable
    {
        builder.Property(e => e.LastUpdatedAt)
            .IsRequired()
            .HasDefaultValueSql(defaultValueSql.Sql);

        return builder;
    }

    /// <summary>
    /// 批量配置最近更新时间审计
    /// </summary>
    /// <param name="modelBuilder">模型构造器</param>
    /// <returns>模型构造器</returns>
    public static ModelBuilder ConfigureForLastUpdateTimeAuditable(
        this ModelBuilder modelBuilder,
        ITimeAuditableDefaultValueSql defaultValueSql)
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateTimeAuditable>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var lastUpdateTimeAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForLastUpdateTimeAuditable),
                2,
                entity.ClrType);

            lastUpdateTimeAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null), defaultValueSql]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 配置完整时间审计
    /// </summary>
    /// <typeparam name="TEntity">实体类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    /// <param name="defaultValueSql">默认值Sql</param>
    /// <returns>实体类型构造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForFullyTimeAuditable<TEntity>(
        this EntityTypeBuilder<TEntity> builder,
        ITimeAuditableDefaultValueSql defaultValueSql)
        where TEntity : class, IFullyTimeAuditable
    {
        builder
            .ConfigureForCreationTimeAuditable(defaultValueSql)
            .ConfigureForLastUpdateTimeAuditable(defaultValueSql);

        return builder;
    }

    /// <summary>
    /// 批量配置时间审计
    /// </summary>
    /// <param name="modelBuilder">模型构造器</param>
    /// <returns>模型构造器</returns>
    public static ModelBuilder ConfigureForTimeAuditable(
        this ModelBuilder modelBuilder,
        ITimeAuditableDefaultValueSql defaultValueSql)
    {
        modelBuilder
            .ConfigureForCreationTimeAuditable(defaultValueSql)
            .ConfigureForLastUpdateTimeAuditable(defaultValueSql);

        return modelBuilder;
    }
}

时间审计使用默认值SQL尽可能使数据库和代码统一逻辑,即使直接向数据库插入记录也能尽量保证有相关审计数据。只是最近更新时间在更新时实在是做不到数据库级别的自动,用触发器会阻止手动操作数据,所以不用。

时间列的默认值SQL在不同数据库下有差异,因此需要从外部传入,方便根据数据库类型切换。

/// <summary>
/// 实体时间审计默认值Sql
/// </summary>
public interface ITimeAuditableDefaultValueSql
{
    string Sql { get; }
}

public class DefaultSqlServerTimeAuditableDefaultValueSql : ITimeAuditableDefaultValueSql
{
    public static DefaultSqlServerTimeAuditableDefaultValueSql Instance => new();

    public string Sql => "GETDATE()";

    private DefaultSqlServerTimeAuditableDefaultValueSql() { }
}

public class DefaultMySqlTimeAuditableDefaultValueSql : ITimeAuditableDefaultValueSql
{
    public static DefaultMySqlTimeAuditableDefaultValueSql Instance => new();

    public string Sql => "CURRENT_TIMESTAMP(6)";

    private DefaultMySqlTimeAuditableDefaultValueSql() { }
}

操作人审计扩展

/// <summary>
/// 实体操作人审计配置扩展
/// </summary>
public static class OperationUserAuditableEntityModelBuilderExtensions
{
    /// <summary>
    /// 配置实体创建人外键和导航属性
    /// </summary>
    /// <typeparam name="TEntity">实体类型</typeparam>
    /// <typeparam name="TUser">用户实体类型</typeparam>
    /// <typeparam name="TIdentityKey">用户Id类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    /// <returns>实体类型构造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForNavigationIncludedCreationUserAuditable<TEntity, TUser, TIdentityKey>(
        this EntityTypeBuilder<TEntity> builder)
        where TEntity : class, ICreationUserAuditable<TIdentityKey, TUser>
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        builder
            .HasOne(b => b.CreatedBy)
            .WithMany()
            .HasForeignKey(b => b.CreatedById);

        return builder;
    }

    /// <summary>
    /// 批量配置实体创建人外键和导航属性
    /// </summary>
    /// <typeparam name="TUser">用户实体类型</typeparam>
    /// <typeparam name="TIdentityKey">用户Id类型</typeparam>
    /// <param name="modelBuilder">实体构造器</param>
    /// <returns>当前实体构造器</returns>
    public static ModelBuilder ConfigureForNavigationIncludedCreationUserAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey, TUser>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var navigationIncludedCreationUserAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForNavigationIncludedCreationUserAuditable),
                1,
                [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

            navigationIncludedCreationUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 批量配置实体创建人外键,如果有导航属性就同时配置导航属性
    /// </summary>
    /// <typeparam name="TUser">用户实体类型</typeparam>
    /// <typeparam name="TIdentityKey">用户Id类型</typeparam>
    /// <param name="modelBuilder">实体构造器</param>
    /// <returns>当前实体构造器</returns>
    public static ModelBuilder ConfigureForCreationUserOrNavigationIncludedAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);

            MethodInfo creationUserAuditableMethod;
            if (entity.ClrType.IsDerivedFrom<ICreationUserAuditable<TIdentityKey, TUser>>())
            {
                creationUserAuditableMethod = GetEntityTypeConfigurationMethod(
                    nameof(ConfigureForNavigationIncludedCreationUserAuditable),
                    1,
                    [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

                creationUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
            }
        }

        return modelBuilder;
    }

    /// <summary>
    /// 配置实体最近修改人外键和导航属性
    /// </summary>
    /// <typeparam name="TEntity">实体类型</typeparam>
    /// <typeparam name="TUser">用户实体类型</typeparam>
    /// <typeparam name="TIdentityKey">用户Id类型</typeparam>
    /// <param name="builder">实体类型构造器</param>
    /// <returns>实体类型构造器</returns>
    public static EntityTypeBuilder<TEntity> ConfigureForNavigationIncludedLastUpdateUserAuditable<TEntity, TUser, TIdentityKey>(
        this EntityTypeBuilder<TEntity> builder)
        where TEntity : class, ILastUpdateUserAuditable<TIdentityKey, TUser>
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        builder
            .HasOne(b => b.LastUpdatedBy)
            .WithMany()
            .HasForeignKey(b => b.LastUpdatedById);

        return builder;
    }

    /// <summary>
    /// 批量配置实体最近修改人外键和导航属性
    /// </summary>
    /// <typeparam name="TUser">用户实体类型</typeparam>
    /// <typeparam name="TIdentityKey">用户Id类型</typeparam>
    /// <param name="modelBuilder">实体构造器</param>
    /// <returns>当前实体构造器</returns>
    public static ModelBuilder ConfigureForNavigationIncludedLastUpdateUserAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey, TUser>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);
            var navigationIncludedLastUpdateUserAuditableMethod = GetEntityTypeConfigurationMethod(
                nameof(ConfigureForNavigationIncludedLastUpdateUserAuditable),
                1,
                [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

            navigationIncludedLastUpdateUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
        }

        return modelBuilder;
    }

    /// <summary>
    /// 批量配置实体最近修改人外键,如果有导航属性就同时配置导航属性
    /// </summary>
    /// <typeparam name="TUser">用户实体类型</typeparam>
    /// <typeparam name="TIdentityKey">用户Id类型</typeparam>
    /// <param name="modelBuilder">实体构造器</param>
    /// <returns>当前实体构造器</returns>
    public static ModelBuilder ConfigureForLastUpdateUserOrNavigationIncludedAuditable<TUser, TIdentityKey>(
        this ModelBuilder modelBuilder)
        where TUser : class
        where TIdentityKey : struct, IEquatable<TIdentityKey>
    {
        foreach (var entity
            in modelBuilder.Model.GetEntityTypes()
                .Where(static e => !e.HasSharedClrType)
                .Where(static e => e.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey>>()))
        {
            var entityTypeBuilderMethod = GetEntityTypeBuilderMethod(entity);

            MethodInfo lastUpdateUserAuditableMethod;
            if (entity.ClrType.IsDerivedFrom<ILastUpdateUserAuditable<TIdentityKey, TUser>>())
            {
                lastUpdateUserAuditableMethod = GetEntityTypeConfigurationMethod(
                    nameof(ConfigureForNavigationIncludedLastUpdateUserAuditable),
                    1,
                    [entity.ClrType, typeof(TUser), typeof(TIdentityKey)]);

                lastUpdateUserAuditableMethod.Invoke(null, [entityTypeBuilderMethod.Invoke(modelBuilder, null)]);
            }
        }

        return modelBuilder;
    }
}

没有导航属性的接口是为用户表在其他数据库的情况预留的,因此这个版本的接口不做作任何特殊配置。

数据库上下文

// 其中IdentityKey是int的全局类型别名,上下文类型继承自Identity Core上下文,用于演示操作用户自动审计
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : ApplicationIdentityDbContext<
        ApplicationUser,
        ApplicationRole,
        IdentityKey,
        ApplicationUserClaim,
        ApplicationUserRole,
        ApplicationUserLogin,
        ApplicationRoleClaim,
        ApplicationUserToken>(options)
{
    // 其他无关代码

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 其他无关代码

        // 自动根据数据库类型进行数据库相关的模型配置
        switch (Database.ProviderName)
        {
            case _msSqlServerProvider:
                modelBuilder.ApplyConfigurationsFromAssembly(
                    typeof(LogRecordEntityTypeConfiguration).Assembly,
                    type => type.GetCustomAttributes<DatabaseProviderAttribute>().Any(a => a.ProviderName is _msSqlServerProvider));

                modelBuilder.ConfigureForTimeAuditable(DefaultSqlServerTimeAuditableDefaultValueSql.Instance);
                break;
            case _pomeloMySqlProvider:
                modelBuilder.ApplyConfigurationsFromAssembly(
                    typeof(LogRecordEntityTypeConfiguration).Assembly,
                    type => type.GetCustomAttributes<DatabaseProviderAttribute>().Any(a => a.ProviderName is _pomeloMySqlProvider));

                modelBuilder.ConfigureForTimeAuditable(DefaultMySqlTimeAuditableDefaultValueSql.Instance);
                break;
            case _msSqliteProvider:
                goto default;
            default:
                throw new NotSupportedException(Database.ProviderName);
        }

        // 配置其他数据库中立的模型配置
        modelBuilder.ConfigureForIOptimisticConcurrencySupported();

        modelBuilder.ConfigureForCreationUserOrNavigationIncludedAuditable<ApplicationUser, IdentityKey>();
        modelBuilder.ConfigureForLastUpdateUserOrNavigationIncludedAuditable<ApplicationUser, IdentityKey>();
    }
}

项目使用MySQL,而VS会附带一个SqlServer单机版,所以暂时使用这两个数据库进行演示,如果需要支持其他数据库,可自行改造。

EF Core侦听器

并发检查侦听器

/// <summary>
/// 为并发检查标记设置值,如果有逻辑删除实体,应该位于逻辑删除拦截器之后
/// </summary>
public class OptimisticConcurrencySupportedSaveChangesInterceptor : SaveChangesInterceptor
{
    protected IServiceScopeFactory ScopeFactory { get; }

    public OptimisticConcurrencySupportedSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    {
        ArgumentNullException.ThrowIfNull(scopeFactory);

        ScopeFactory = scopeFactory;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        OnSavingChanges(eventData);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        OnSavingChanges(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    /// <summary>
    /// 处理实体的并发检查令牌,并忽略由<see cref="ShouldProcessEntry"/>排除的实体
    /// </summary>
    /// <param name="eventData"></param>
    protected virtual void OnSavingChanges(DbContextEventData eventData)
    {
        ArgumentNullException.ThrowIfNull(eventData.Context);

        eventData.Context.ChangeTracker.DetectChanges();

        var entries = eventData.Context.ChangeTracker.Entries()
            .Where(static e => e.State is EntityState.Added or EntityState.Modified)
            .Where(ShouldProcessEntry);

        foreach (var entry in entries)
        {
            if (entry.Entity is IOptimisticConcurrencySupported optimistic)
            {
                if (entry.State is EntityState.Added)
                {
                    optimistic.ConcurrencyStamp = Guid.NewGuid().ToString();
                }
                if (entry.State is EntityState.Modified)
                {
                    // 如果是更新实体,需要分别处理原值和新值
                    var concurrencyStamp = entry.Property(nameof(IOptimisticConcurrencySupported.ConcurrencyStamp));
                    // 实体的当前值要指定为原值
                    concurrencyStamp!.OriginalValue = (entry.Entity as IOptimisticConcurrencySupported)!.ConcurrencyStamp;
                    // 然后重新生成新值
                    concurrencyStamp.CurrentValue = Guid.NewGuid().ToString();
                }
            }
        }
    }

    /// <summary>
    /// 用于排除在其他位置处理过并发检查令牌的实体
    /// </summary>
    /// <param name="entry">实体</param>
    /// <returns>如果应该由当前拦截器处理返回<see langword="true"/>,否则返回<see langword="false"/>。</returns>
    protected virtual bool ShouldProcessEntry(EntityEntry entry) => true;
}

/// <summary><inheritdoc cref="OptimisticConcurrencySupportedSaveChangesInterceptor"/></summary>
/// <remarks>忽略用户实体的并发检查令牌,Identity服务已经处理过实体</remarks>
public class IdentityOptimisticConcurrencySupportedSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    : OptimisticConcurrencySupportedSaveChangesInterceptor(scopeFactory)
{
    /// <summary>
    /// 忽略Identity内置并发检查的实体
    /// </summary>
    /// <param name="entry">待检查的实体</param>
    /// <returns>不是IdentityUser的实体</returns>
    protected override bool ShouldProcessEntry(EntityEntry entry)
    {
        var type = entry.Entity.GetType();
        var isUserOrRole = type.IsDerivedFrom(typeof(IdentityUser<>)) || type.IsDerivedFrom(typeof(IdentityRole<>));
        return !isUserOrRole;
    }
}

Identity Core有一套内置的并发检查处理机制,因此需要对Identity相关实体进行排除,防止重复处理引起异常。

时间审计侦听器

/// <summary>
/// 为操作时间审计设置值,如果已经手动设置有效值,不会再次设置。如果有逻辑删除实体,应该位于逻辑删除拦截器之前。<br/>
/// 删除时间已经由逻辑删除标记保留,不应该用删除时间覆盖更新时间,在逻辑删除之前使用避免误操作由逻辑删除拦截器设置的已编辑的实体。
/// </summary>
public class OperationTimeAuditableSaveChangesInterceptor : SaveChangesInterceptor
{
    protected IServiceScopeFactory ScopeFactory { get; }

    /// <summary>
    /// 为操作时间审计设置值,如果已经手动设置有效值,不会再次设置。如果有逻辑删除实体,应该位于逻辑删除拦截器之前。<br/>
    /// 删除时间已经由逻辑删除标记保留,不应该用删除时间覆盖更新时间,在逻辑删除之前使用避免误操作由逻辑删除拦截器设置的已编辑的实体。
    /// </summary>
    /// <param name="scopeFactory"></param>
    public OperationTimeAuditableSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    {
        ArgumentNullException.ThrowIfNull(scopeFactory);

        ScopeFactory = scopeFactory;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        OnSavingChanges(eventData);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        OnSavingChanges(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    /// <summary>
    /// 处理实体的审计时间
    /// </summary>
    /// <param name="eventData"></param>
    protected virtual void OnSavingChanges(DbContextEventData eventData)
    {
        ArgumentNullException.ThrowIfNull(eventData.Context);

        using var scope = ScopeFactory.CreateScope();
        var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();

        eventData.Context.ChangeTracker.DetectChanges();

        var entries = eventData.Context.ChangeTracker.Entries()
            .Where(static e => e.State is EntityState.Added or EntityState.Modified);

        foreach (var entry in entries)
        {
            if(entry is { Entity: ICreationTimeAuditable creation, State: EntityState.Added })
            {
                if(creation.CreatedAt is null || creation.CreatedAt == default)
                {
                    creation.CreatedAt = timeProvider.GetLocalNow();
                }
            }

            if (entry is { Entity: ILastUpdateTimeAuditable update, State: EntityState.Added or EntityState.Modified })
            {
                if (entry.Property(nameof(update.LastUpdatedAt)).IsModified) { }
                else if (update.LastUpdatedAt is null || update.LastUpdatedAt == default)
                {
                    update.LastUpdatedAt = timeProvider.GetLocalNow();
                }

                if (entry is { Entity: ICreationTimeAuditable, State: EntityState.Modified })
                {
                    entry.Property(nameof(ICreationTimeAuditable.CreatedAt)).IsModified = false;
                }
            }
        }
    }
}

操作人审计侦听器

/// <summary>
/// 为操作人审计设置值,如果已经手动设置有效值,不会再次设置。如果有逻辑删除实体,应该位于逻辑删除拦截器之后。<br/>
/// 到此处依然处于删除状态的实体应该是物理删除,记录审计信息没有意义。
/// </summary>
public class OperatorAuditableSaveChangesInterceptor<TIdentityKey> : SaveChangesInterceptor
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    protected IServiceScopeFactory ScopeFactory { get; }

    /// <summary>
    /// 为操作人审计设置值,如果已经手动设置有效值,不会再次设置。如果有逻辑删除实体,应该位于逻辑删除拦截器之后。<br/>
    /// 到此处依然处于删除状态的实体应该是物理删除,记录审计信息没有意义。
    /// </summary>
    /// <param name="scopeFactory"></param>
    public OperatorAuditableSaveChangesInterceptor(IServiceScopeFactory scopeFactory)
    {
        ArgumentNullException.ThrowIfNull(scopeFactory);

        ScopeFactory = scopeFactory;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        OnSavingChanges(eventData);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        OnSavingChanges(eventData);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    /// <summary>
    /// 处理实体的审计操作人
    /// </summary>
    /// <param name="eventData"></param>
    protected virtual void OnSavingChanges(DbContextEventData eventData)
    {
        ArgumentNullException.ThrowIfNull(eventData.Context);

        using var scope = ScopeFactory.CreateScope();
        var operatorAccessor = scope.ServiceProvider.GetRequiredService<IOperatorAccessor<TIdentityKey>>();

        eventData.Context.ChangeTracker.DetectChanges();

        var entries = eventData.Context.ChangeTracker.Entries()
            .Where(static e => e.State is EntityState.Added or EntityState.Modified);

        foreach (var entry in entries)
        {
            if (entry is { Entity: ICreationUserAuditable<TIdentityKey> creation, State: EntityState.Added })
            {
                if (creation.CreatedById is null || creation.CreatedById.Value.Equals(default))
                {
                    creation.CreatedById = operatorAccessor.GetUserId();
                }
            }

            if (entry is { Entity: ILastUpdateUserAuditable<TIdentityKey> update, State: EntityState.Added or EntityState.Modified })
            {
                if (entry.Property(nameof(update.LastUpdatedById)).IsModified) { }
                else if (update.LastUpdatedById is null || update.LastUpdatedById.Value.Equals(default))
                {
                    update.LastUpdatedById = operatorAccessor.GetUserId();
                }

                if (entry is { Entity: ICreationUserAuditable<TIdentityKey>, State: EntityState.Modified })
                {
                    entry.Property(nameof(ICreationUserAuditable<TIdentityKey>.CreatedById)).IsModified = false;
                }
            }
        }
    }
}

/// <summary>
/// 实体操作人的用户Id提供服务
/// </summary>
/// <typeparam name="TIdentityKey">用户Id类型</typeparam>
public interface IOperatorAccessor<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>
{
    /// <summary>
    /// 获取用户Id
    /// </summary>
    /// <returns>用户Id</returns>
    TIdentityKey? GetUserId();

    /// <summary>
    /// 异步获取用户Id
    /// </summary>
    /// <param name="cancellation">取消令牌</param>
    /// <returns>用户Id</returns>
    Task<TIdentityKey?> GetUserIdAsync(CancellationToken cancellation = default);
}

/// <summary>
/// 使用Http上下文获取实体操作人的用户Id
/// </summary>
/// <typeparam name="TIdentityKey"><inheritdoc cref="IOperatorAccessor{TIdentityKey}"/></typeparam>
/// <param name="contextAccessor">Http上下文访问器</param>
/// <param name="options">Identity选项</param>
public class HttpContextUserOperatorAccessor<TIdentityKey>(
    IHttpContextAccessor contextAccessor,
    IOptions<IdentityOptions> options)
    : IOperatorAccessor<TIdentityKey>
    where TIdentityKey : struct, IEquatable<TIdentityKey>, IParsable<TIdentityKey>
{
    public TIdentityKey? GetUserId()
    {
        var success = TIdentityKey.TryParse(contextAccessor.HttpContext?.User.Claims.FirstOrDefault(c => c.Type == options.Value.ClaimsIdentity.UserIdClaimType)!.Value, null, out var id);
        return success ? id : null;
    }

    public Task<TIdentityKey?> GetUserIdAsync(CancellationToken cancellation = default)
    {
        return Task.FromResult(GetUserId());
    }
}

实体操作人的获取在定义侦听器的时候是未知的,所以获取方式需要通过接口从外部传入。此处以用ASP.NET Core Identity获取用户Id为例。

侦听器统一使用作用域工厂服务使其能和依赖注入系统紧密配合,然后使用内部作用域即用即取,用完立即销毁的方式避免内存泄露。

配置服务

一切准备妥当后就可以在主应用里配置相关服务让功能可以正常运行。

public void ConfigureServices(IServiceCollection services)
{
    // 实体操作人审计EF Core拦截器需要使用此服务获取操作人信息
    services.AddScoped(typeof(IOperatorAccessor<>), typeof(HttpContextUserOperatorAccessor<>));

    // 注册基于缓冲池的数据库上下文工厂
    services.AddPooledDbContextFactory<ApplicationDbContext>((sp, options) =>
    {
        // 注册拦截器
        var scopeFactory = sp.GetRequiredService<IServiceScopeFactory>();
        options.AddInterceptors(
            new OperationTimeAuditableSaveChangesInterceptor(scopeFactory),
            new IdentityOptimisticConcurrencySupportedSaveChangesInterceptor(scopeFactory),
            new OperatorAuditableSaveChangesInterceptor<IdentityKey>(scopeFactory));

        // 其它代码
    });

    // 其它代码
}

由于拦截器对象是长期存在且脱离依赖注入的特殊对象,因此需要从外部传入作用域工厂使其能够使用依赖注入的相关功能和整个ASP.NET Core应用更紧密的集成。拦截器和ASP.NET Core中间件一样顺序会影响结果,因此要认真考虑如何安排。

结语

如此一番操作之后,操作时间、操作用户审计和乐观并发就全自动化了,一般业务代码可以0修改完成集成。如果手动操作相关属性,侦听器也会优先采用手动操作的结果保持充足的灵活性。

示例代码:
SoftDeleteDemo.rar
。主页显示异常请在libman.json上右键恢复前端包。

QQ群

读者交流QQ群:540719365
image

欢迎读者和广大朋友一起交流,如发现本书错误也欢迎通过博客园、QQ群等方式告知笔者。

本文地址:
https://www.cnblogs.com/coredx/p/18305165.html