2024年12月


公钥密码的基础

以下是公钥密码学一些关键点,

公钥可以发送给任何人,它是公开的。
必须保护好私钥。如果中间方获得私钥,他们就能解密私信。
计算机可以使用公钥快速加密消息,使用私钥快速解密消息。如果没有私钥,计算机需要很长一段时间(数百万年)才能暴力破解加密消息。

公钥密码学原理:
陷门函数

对于所有公钥密码学算法来说,最关键的是它们都有自己独特的陷门函数(Trapdoor Function)。
陷门函数是一种只能单向计算
,至少是只在一个方向上易于计算的函数。(如果使用现代计算机从另一个方向暴力破解,需要数百万年时间。)

非陷门函数的例子:A + B = C

已知 A 和 B,我就能计算出 C 。问题在于,在已知 B 和 C 的情况下,我也能计算出 A 。这就是非陷门函数。

陷门函数演示流程:

“I love Fox and Friends” + Public Key = “s80s1s9sadjds9s”

已知 “I love Fox and Friends” 和公钥,我可以计算出 “s80s1s9sadjds9s” ,但是已知 “s80s1s9sadjds9s” 和公钥,我无法计算出 “I love Fox and Friends” 。

在 RSA 算法中,陷门函数取决于将一个巨大的数分解成质因数的难易程度。

公钥:944,871,836,856,449,473
私钥:961,748,941 和 982,451,653

在上述例子中,公钥是一个很大的数,私钥是公钥的两个质因数。这是一个很好的例子,因为将私钥中的数相乘,很容易就能算出公钥,但是你只有公钥的话,需要很长时间才能使用计算机算出私钥。

注:在真正的密码学实践中,私钥的长度必须超过 200 位才能被视为是安全的。

ECC 引入

椭圆曲线公钥密码算法简称ECC,ECC 与 RSA 的用途相同。
ECC 会生成一个公钥和私钥,允许双方安全通信。
不过,ECC 相比 RSA 有一大优势。一个 256 位的 ECC 密钥与一个 3072 位的 RSA 密钥安全性相同。也就是说,在资源有限的系统(如智能手机、嵌入式计算机和加密货币网络)中,ECC 密钥需占用的硬盘空间和带宽是 RSA 密钥的 10% 不到。

Principle

ECC 与 RSA 的
主要区别
在于陷门函数。
ECC 的陷门函数类似于数学版的台球游戏。我们先在曲线上找到一个特定的点,然后使用函数(通常称为点函数)在曲线上找到一个新的点,接着重复使用点函数,在曲线上不断跳跃,直到找到最后一个点为止。我们来看一下该算法的具体步骤:

image

椭圆曲线公钥密码算法原理:

从 A 点开始:A dot B = -C(从 A 点至 B 点画一条直线,与曲线相交于 -C 点)-C 点经过 X 轴反射到曲线上的 C 点A dot C = -D (从 A 点至 C 点画一条直线,与曲线相交于 -D 点)-D 点经过 X 轴反射到曲线上的 D 点A dot D = -E (在 A 点至 D 点画一条直线,与曲线相交于 -E 点)-E 点经过 X 轴反射到曲线上的 E 点。

这是一个很棒的陷门函数,因为如果你知道起点(A)在哪里,以及到达终点(E)需要经历多少次跳跃,很容易就能找到终点。但是,如果你只知道起点 A 和终点 E 在哪里,几乎不可能知道中间经历了几次跳跃。

公钥:
起点 A 、终点 E
私钥:
从 A 点至 E 点需要经历几次跳跃

如果转化为加密算法?如何创建公钥和私钥?如何用它们来加密数据?
TODO。。。

椭圆曲线加密算法的安全性如何?

虽然 RSA 加密算法具有极高的安全性,但 ECC 可以说是更胜一筹。

理论上,量子计算机或可有效解决 RSA 所依赖的因数分解问题,从而破解 RSA。 这种情况是否会很快成真,是一个很有争议的问题。 但我们可以肯定地说,考虑到 ECC 的复杂性,与 RSA 相比,它更能抵抗量子计算攻击。

有多大的抵抗能力? 荷兰数学家 Arjen Lenstra 在与他人合著的一篇研究论文中,将破解加密算法与烧水进行比较。 基本概念是计算出破解一个特定的加密算法需要多少能量,然后计算这些能量可以煮沸多少水。 通过这样的类比,破解一个 228 位 RSA 密钥所需的能量比煮沸一茶匙水所需的能量还少,但破解一个 228 位 ECC 密钥所消耗的能量可以煮沸地球上所有的水。 要达到相同的安全级别,RSA 密钥的长度需要达到 2380 位。

Reference:

https://www.panewslab.com/zh/articledetails/D55038644.html
https://www.keepersecurity.com/blog/zh-hans/2023/06/07/what-is-elliptic-curve-cryptography/

总览

该笔记包含了原课程中关于并发控制的四节课的内容:

  • 并发控制理论(Concurrency Control Theory)
  • 二阶段锁并发控制(Two-Phase Locking Concurrency Control)
  • 乐观并发控制/基于时间戳(Optimistic Concurrency Control)
  • 多版本并发控制(Multiple-Version Concurrency Control)

ACID

并发控制与数据库恢复一体两面,在数据库系统中设计到如下部分:

数据库为达成一个目的的语句块称为一个事务,特殊地,一个语句本身就可以视为一个事务。

并发控制与数据库恢复的目标是实现事务的
ACID

  • Atomicity
    :全或无。
  • Consistency
    :数据整体一致;分布式场景下,强一致或最终一致。
  • Isolation
    :事务并行,但是互不干涉。
  • Durability
    :事务提交,就保证修改已经持久化。

image-20241128122657667

串行化与冲突操作

如何确定事务并发的执行结果是满足隔离性(
Insolation
)的?
答:事务是可串行化的,即并行调度结果和串行调度一致。

串行调度(
Serial Schedule
):数据库每次只执行一个事务,不并行。

可串行化调度(
Serializable Schedule
):允许事务并行,但是并行执行的结果可以等效为串行调度。

为什么有的事务是可串行化的,而有的无法串行化?
答:取决于是否包含(读写)冲突操作,存在冲突操作时,往往就是不可串行化的。

冲突操作带来并发问题:

  • 读写冲突(
    R-W
    ):不可重复读,幻读[严格来说是
    Scan / Insert
    ]
  • 写读冲突(
    W-R
    ):脏页读
  • 写写冲突(
    W-W
    ):更新丢失

image-20241129143220423

如何判断一组事务是可串行化的?

答:依赖关系图(
Dependency Graph
)不成环。

(脏读一般是回滚时才考虑)

image-20241120165135138

隔离级别

数据不一致问题与隔离级别对照图。

Dirty Read Unrepeatable Read Lost Update Phantom
Serializable No No No No
Repeatable Read No No No Maybe
Read Committed No Maybe Maybe Maybe
Read Uncommitted Maybe Maybe Maybe Maybe

不同数据库支持的隔离级别。

其中比较特殊的是Oracle最高支持的是
Snapshot Isolation
而不是
Serializable

image-20241129122622520

完整的隔离级别层级图。

image-20241129122743274

事务的串行化调度和串行化隔离级别的关系:

  • 串行化调度(
    Serializable Schedule
    ):保证事务一致性,指两个事务并发时,效果等价于串行执行,即依赖图不成环。

  • 串行化隔离级别(
    Serializable Level
    ):指不出现上文的四个并发问题。

  • 一般认为,满足 串行化隔离级别 时,事务间就可以实现串行化调度。

概念层级

并发控制实现的目标是
事务的ACID
,但由于存在
冲突操作
,导致出现一系列并发问题,造成数据不一致,为了权衡性能与并发问题的错误程度,定义了不同的
隔离级别
,为了实现不同的隔离级别,有各样的
并发控制机制
,如二阶段锁,OOC,索引锁等等。

概念由抽象到具体,从顶层到底层,结构图如下:

下面主要介绍不同的
并发控制机制
的原理与解决的问题。

二阶段锁

原理

有两类锁,共享锁和互斥锁。

S-Lock X-Lock
S-Lock
X-Lock

二阶段锁如何解决并发问题?

  • 最粗的粒度:事务开始就加锁,事务提交时释放锁,此时是严格的串行化,但是效率不高。
  • 最细的粒度:操作时加锁,操作结束就释放锁,没有解决依赖图成环的问题,依然是非串行化的。
image
image
  • 二阶段锁:分为两个阶段,锁获取阶段和锁释放阶段,只有获取完所有锁以后才能释放锁。

image-20241128144630780

由于获取完所有锁才会释放,所以依赖图不会成环(见“串行化与冲突操作”一节),但是由于我们无法对Abort操作加锁,并且插入或删除元素时也无法加锁,因此
二阶段锁可以解决不可重复读和更新丢失,无法解决脏读和幻读。

级联回滚

脏读会带来级联回滚(
Cascading Abort
)的问题,见下图。

\(T_2\)
读取了
\(T_1\)
未提交的数据,导致
\(T_1\)
回滚时,
\(T_2\)
也需要回滚。

image-20241128151540707

强二阶段锁

强二阶段锁(
String Strict 2PL
):不是操作结束后立刻解锁,而是在事务提交时统一解锁。

解决了脏读问题。因为由于事务提交前不释放锁,所以另一个事务无法读到刚修改的数据。

image-20241128152347036

死锁检测和避免

一个死锁的例子:锁交错。

image-20241128153128615

死锁检测:检测是否成环

时序图上体现为交叉,在依赖图上体现为环。

image-20241128153513785

处理方式:选择一个事务进行回滚。

如何选择:依照年龄,执行进度,锁数量等。

如何回滚:全部回滚;部分回滚:设置savepoint,回滚到savepoint。

死锁避免:设置优先级,保证锁单向传递,不产生交错

  • 事务的开始时间越早,优先级越高
  • 分类:非抢占式(
    Wait-Die
    ),抢占式(
    Wound-Wait
高->低 低->高
非抢占式 高优先级等待【爱幼】 低优先级放弃
抢占式 高优先级抢占 低优先级等待【尊老】

没有双向等待,也就不会产生交错,也就不会有死锁。

image-20241128155123174

锁层级

数据库需要维护不同层级(Hierarchical)的锁来保证并发度。

image-20241128160942998

意向锁:在给子层级加锁时,给父层级加意向锁,兼容矩阵如下。

image-20241128162450411

实践应用

Select ... For Update

BEGIN;
SELECT balance FROM Accounts WHERE account_id = 1 FOR UPDATE;
UPDATE Accounts SET balance = balance - 100 WHERE account_id = 1;
COMMIT;

扣减余额时,先select再update:

  • select时,
    account=1
    的tuple上的是S锁,父结点上的是IS锁;
  • update时,
    accnount=1
    的tuple需要上X锁,父结点升级为ISX锁;
  • 所以可以通过
    Select ... For Update
    ,一开始就给tuple上X,给父结点上ISX锁

Select ... Skip Locked

可以跳过锁,避免阻塞。

例如:

SELECT task_id, task_data
FROM task_queue
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1;
  • 一个任务表存储了待处理任务,每个任务由不同的线程负责处理:
  • 每个线程获取一个未锁定的任务进行处理;
  • 已被其他线程锁定的任务会被跳过,从而提高并发处理效率

实现的隔离级别

  1. SERIALIZABLE
    :强二阶段锁+幻读预防措施(如索引锁,见后文)。
  2. REPEATABLE READS
    :强二阶段锁。

OOC

乐观的并发控制(Optimistic Concurrency Control)。

原理

假设大多数时候没有冲突,先执行操作,操作结束后再进行一次验证。

  • 如果确实没有冲突,提交事务,写入结果
  • 如果有冲突,回滚,重新进行

三个阶段

  1. 读取(
    Read Phase
    ):每个事务有一个私有的存储空间,当访问元组时,将访问结果读取到该空间中,后续的操作都在该空间进行。
  2. 验证(
    Validation Phase
    ):
    赋予事务一个时间戳
    ,并校验是否有冲突,即是否满足下面的条件。
    • \(WriteSet(T1) ∩ ReadSet(T2) = Ø\)
    • 如果此时事务2还处于读取阶段,那么还需要满足:
      \(WriteSet(T1) ∩ WriteSet(T2) = Ø\)
  3. 写入(
    Write Phase
    ):写入结果,此时修改其他事务可见。

image-20241129172951290

image-20241129173524368

实现的隔离级别

如何解决并发问题:并发问题的图示见上文“串行化与冲突操作“一节

当处于验证阶段时,如果
\(T_1<T_2\)

  • 由于读的是副本:不会出现
    脏读

    不可重复读
    问题。

  • 由于读的是副本:如果
    \(WriteSet(T1) ∩ ReadSet(T2) ≠ Ø\)

    \(T_2\)
    理应看到
    \(T_1\)
    的更新值,但是由于
    \(T_1\)
    还没有把结果写入磁盘,所以
    \(T_2\)
    读的是副本,而不是
    \(T_1\)
    的更新值。

    所以此时存在数据不一致问题,因此要保证
    \(WriteSet(T1) ∩ ReadSet(T2) = Ø\)

  • \(WriteSet(T1) ∩ WriteSet(T2) = Ø\)
    :解决
    更新丢失
    问题。

  • 没有解决
    幻读
    问题。

反例1:
\(WriteSet(T1) ∩ ReadSet(T2) ≠ Ø\)
,此时
\(T_2\)
没有读到
\(T_1\)
的更新。

image-20241129173806337

反例2:
\(WriteSet(T1) ∩ WriteSet(T2) ≠ Ø\)
,可能出现更新丢失问题。

image-20241129180539646

注:在验证阶段,我么都是与未提交的事务进行校验,称为前向校验(
Forward Validation
)。

image-20241129181019340

当然也可以与已提交的事务进行校验,称为后向校验(
Backward Validation
)。

image-20241129181059663

总结:

  1. SERIALIZABLE
    :OOC+幻读预防措施(如索引锁,见后文)。
  2. REPEATABLE READS
    :OOC。

处理幻读

主要采用索引锁(
Index Lock
)的方式。

在插入数据时,锁住索引见的间隙(
Gap
),从而阻止插入或删除。

image-20241129182341555

image-20241129182324843

更进一步:给索引锁也加上意向锁这个层级。

image-20241129182426013

MVC

原理

基本思想:事务通过元组(
Tuple
)的版本,判断可见性。

  • 版本:解决我能看到谁


    • 三元组
      [begin-Txn, end-Txn, value]
    • 读操作:置
      begin-Txn
    • 写操作:置新值
      begin-Txn
      ,旧值
      end-Txn
  • 事务活动表:解决我能不能访问我看到的,必要时借助锁

image
image
image
image
image
image
image
image

从上述例子中,可以看出MVCC解决了

  • 脏读,不可重复读

  • 更新丢失

  • 幻读
    依然没有解决,需要结合索引锁等机制

写偏差异常(Write Skew Anomaly)

只检测直接的写冲突,无法捕获事务之间的隐式逻辑依赖,导致会违背全局约束。

假设有一个医院的医生值班系统,要求
任何时刻至少有一名医生值班
。表 Shifts 记录了当前医生是否值班:

DoctorID OnDuty
1 Yes
2 Yes
  • T1
    : 医生 1 决定取消自己的值班,读取当前值班情况,发现医生 2 仍在值班,于是提交一个事务将自己从值班中移除。

  • T2
    : 医生 2 决定取消自己的值班,读取当前值班情况,发现医生 1 仍在值班,于是提交一个事务将自己从值班中移除。

过程

  1. T1

    T2
    基于同一个快照读取 Shifts 表,发现当前有另一名医生在值班(医生 2 和医生 1)。
  2. T1

    T2
    分别更新自己的记录,将 OnDuty 设置为 No。
  3. 两个事务没有直接修改相同的记录,因此快照隔离认为没有写冲突,允许它们同时提交。
  4. 提交后,Shifts 表中所有医生的 OnDuty 均为 No,违反了至少 2 名医生值班的约束。

版本存储(Version-storage)

元组的版本信息如何存储:

  • Append only:新旧版本在同一张表空间
  • Time travel storage:新旧版本分开
  • Delta Storage:不存储实际值,而是存储增量delta

Append Only

每个逻辑元组的所有物理版本都存储在一个相同的表空间中。

不同逻辑元组的物理版本之间用链表串联。

当更新时,添加一个新的物理版本到的表空间中,如下图所示(省略了
begin_Txn

end_Txn
)。

image-20241202130033051

链表的串联顺序可以是由旧到新
Oldest-to-Newest (O2N)
,也可以是由新到旧
Newest-to-Oldest (N2O)

Time Travel Storage

有一张主表和一张历史版本表,当更新的时候,把旧版本写入历史版本表中,然后新版本写到主表上。

比如写入
\(A_3\)
时,先写把
\(A_2\)
写到历史版本表,维护相应指针,然后把
\(A_3\)
写入主表。

image-20241202131600382

Delta Storage

依然是有猪逼哦啊和历史版本表,但是历史版本表存储增量而不是实际值。


\(A_1=111 \rightarrow A_2 = 222 \rightarrow A_3 = 333\)
的版本记录记录如下。

image-20241202132120421

垃圾回收

事务的所有历史版本记录那都是存放在表空间中,久而久之就会不断堆积,所以对于没有用的版本记录,需要及时回收。

可回收的版本记录:

  1. 活跃事务都不可见的版本。
  2. 终止(
    abort
    )的事务的版本。

垃圾回收的目标:找到上述两类过期版本,并将它们安全地删除。

垃圾回收的两个层级:

  1. 元组层级(
    Tuple Level
  2. 事务层级(
    Transaction Level)

Tuple Level

Background Vacuuming

后台清理线程集中化清理,适用于所有版本存储方式。

清理现场扫描历史版本表,将每个历史版本的
begin_Txn

end_Txn
与当前所有活跃的事务的id进行比较,判断是否可以清理。

image-20241203102054656

如上图,清除了
\(A_{100}\)

\(B_{100}\)

改进:添加脏页位图,快速跳转到代清理的版本。

image-20241203102124716

Cooperative Cleaning

分布式清理,清理任务分摊到每个工作线程,适用于O2N。

全局维护一个事务id(
Txn
),表示当前活跃事务的最小id,当每个工作线程在自己的历史版本表中寻找自己的可见版本时,顺带清理掉那些全局不可见的版本。

image-20241203102306935

Transition Level

集中化清理,但是旧版本收集分摊到工作线程,适用于所有版本存储方式。

当事务创建了一个新版本后,将旧版本提交给中心清理线程,中心线程统一清理旧版本。

image-20241203103154830

image-20241203103219349

image-20241203103234459

对比

属性 Background Vacuuming Cooperative Cleaning Transition Level Vacuuming
触发方式 后台任务自动触发 事务或查询过程中触发 根据事务需求动态触发
旧版本收集 清理线程 工作线程 工作线程
旧版本清理 清理线程 工作线程 清理线程
系统资源开销 占用额外资源 分散到用户操作中 智能调度,可能额外增加开销
适用场景 数据量大,需释放磁盘空间 实时查询环境 隔离级别需求高的环境
优缺点平衡 减少用户影响但有清理滞后风险 实时性好但增加用户操作开销 智能性高但复杂度和判断开销增加

索引管理

二级索引维护方式

  1. 逻辑指针(
    Logical Pointers
    ):二级索引使用每个元组的固定标识符(例如主键)来指向数据。间接访问,需要回表。
  2. 物理指针(
    Physical Pointers
    ):直接使用物理地址指向版本链的头部。直接访问,无需回表,但是维护困难。
image
image

索引重复值问题(
Duplicate Key Problem
):

MVCC中不同时间的事务会看到元组的不同版本,所以一个元组会有不同的索引,指向不同的物理版本。

如下图,
begin_Txn < 30
的事务看到的是A已删除,而
begin_Txn >= 30
的事务看到的是A=30。

image-20241203111257907

删除

如何表示一个版本被删除。

  1. 版本上添加一个标识位,标识已删除
  2. 新建一个空版本标识已删除,

image-20241203112652523

各个数据库的实现方式

image-20241203112828671

Index Management

  • Secondary Indexes
    • Logical Pointers
    • Physical Pointers
    • Multiple key Problem(GC)

总结

2PL,OOC,MVCC都是实现事务ACID的方式。

2PL运用锁来控制并发,比较底层;OOC先执行,后利用时间戳检测,适合冲突少的情况;MVCC利用版本控制,除了可以控制并发正确性,还能进行版本回溯,是当前的主流方式。

强二阶段锁,OOC和MVCC都能避免脏读,不可重复读和更新丢失,但是无法避免幻读,需要额外利用其他机制,如索引锁。

MVCC会有写偏差异常
(Write Skew Anomaly)
,无法实现完全到串行化的隔离级别,往往和其他并发控制机制如2PL,OOC结合使用。

image-20241203121010635

项目简介

自 Natasha v9.0 发布起,我将基于 Natasha 的推出热执行方案,这项技术允许基于 控制台(Console) 和新版 Asp.net Core 架构的项目在运行中动态重编译,在不停止工程的情况下获取最新结果,以帮助技术初学者、项目初期开发人员等,进行快速实验以及试错。

为了更形象的说明 [热执行] 请看下图:
HE

热执行

以下为了更加简洁,称热执行为 [HE].
图中是 Asp.net Core 一个接口开发的案例,我更改了一个实体类的结构,并保存,可以看到接口返回了最新的实体类结构。借此简单阐述一些热执行的工作原理,文件发生变化会触发 [HE] 对项目进行热编译,开发者无论是大改还是小改,只要你的项目文件(cs) 、依赖项目、csproj 发生了变化,[HE] 就会代理整个项目并自动编译输出。对于有些老机器较慢,可能 [HE] 热编译要比 [按下F5-程序跑起来] 要快的多。

热重载与热执行

也许有人会觉得这更像一个完全体的热重载,并不是,这是与热重载完全不同的技术,[HE] 的核心技术是语法树重写与动态编译。而热重载是对 Runtime 的程序集进行热更新,热重载严重依赖 Debugger 组件,且目前从
ENC 错误代码
来看这项技术的限制还是很大的。
起初我也是闷头钻研热重载技术,但实验效果很不理想,热重载技术是一项前沿的,边界明确的技术,并不适合敞开手脚快刀阔斧的干,由于不是面对开发者,(截至2024年8月)资料也不是很多。与其死磕它,不如另辟蹊径,借助 Natasha 动态代理将项目管理起来。

指令简介

注释指令

HE 使用注释作为热代理指令,这些指令会影响语法树重建以及热编译选项,但不影响程序的发布和使用。目前具体如下:

  • 优化级别

使用 //HE:Release 指令允许在 HE 重编译时,使用 Release 模式进行编译。

  • 异步代理

当 Main 方法中有对象 A, A 需要延迟卸载,A 不干扰 new A (即全局可以不只有一个 A), 此时使用 //HE:Async 允许 HE代理 在上一次 A 对象未完全销毁时异步执行新 Main 方法。

  • Using 排除

由于开发可能会开启隐式 using, 若开启,则 HE 在代理期间,会加载所有内存中存在的命名空间,因此有概率会出现 using 二义性引用问题,使用 //HE:CS0104 可以排除干扰 using,例如 //HE:CS0104 using1;using2...

  • 动态表达式

如果您需要在 HE 代理期间动态的调试输出一些结果,且不影响程序发布,您可以使用 //DS 或 //RS 指令输出其后的表达式。例如
//DS 1+1
在 Debug 模式下输出 2.
//RS a.age+b.age
在 Release 输出两个对象年龄相加。

  • 参数传递

void ProxyMainArguments()
方法将在代理执行之前执行,该方法允许开发者在动态开发中,在 HE 代理期间模拟 控制台向 main 方法中传递参数。
伪代码类似于:

public static void ProxyMainArguments()
{
      HEProxy.AppendArgs("123");
      HEProxy.AppendArgs("参数2");
      HEProxy.AppendArgs("abc");
}
main("123","参数2","abc");

注意:HE 每次创建新的代理都是一次全新的 main 执行过程,因此将清空 Args, 避免上一次代理干扰本次执行。

  • 仅在程序第一次运行

使用 //Once 命令允许程序仅在程序第一次开启时运行被其注释的代码,在后续的 HE 代理期间,被注释的语法节点将被剔除。

使用

目前该项目支持 .NET3.0 即以上版本,且 .NET5.0 版本以上有 Source Generator 技术加持。

无 SG 加持的版本

  1. 引入热执行包:
    DotNetCore.Natasha.CSharp.HotExecutor
class Program
{

        public static void Main(string[] args)
        {

            //设置当前程序的类型 ,默认为 Console
            HEProxy.SetProjectKind(HEProjectKind.Console);

            //HE 代理周期日志(如果不需要 HE 写入日志,这句就不用写了)
            string debugFilePath = Path.Combine(VSCSProjectInfoHelper.HEOutputPath, "Debug.txt");
            HEFileLogger logger = new HEFileLogger(debugFilePath);

            //设置信息输出方式,该方法影响 DS/RS 指令的输出方式
            //默认是 Console.WriteLine 方式输出
            HEProxy.ShowMessage = async msg => {
                //一些项目可能禁用控制台,那就用日志输出 HE 信息
                await logger.WriteUtf8FileAsync(msg);
            };

            //编译初始化选项,主要是 Natasha 的初始化操作.
            //Once (热编译时使用 Once 剔除被注释的语句)
            HEProxy.SetCompileInitAction(() => {
                {
                    NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
                    NatashaManagement.Preheating((asmName, @namespace) =>

                                !string.IsNullOrWhiteSpace(@namespace) &&
                                (HEProxy.IsExcluded(@namespace)),
                                true,
                                true);
                }
            });

            //开始执行动态代理.
            //Once (热编译时使用 Once 剔除被注释的语句)
            HEProxy.Run();

            for (int i = 0; i < args.Length; i++)
            {
                Console.WriteLine(args[i]);
                //在 HE 代理期间输出 args 值
                //DS args[i]
            }
 
            //while 阻塞时需要指定 CancelToken ,热执行时 HE 将取消循环操作。
            CancellationTokenSource source = new CancellationTokenSource();

            //添加到 HE 中,以便下个编译时释放
            source.ToHotExecutor();

            while (!source.IsCancellationRequested)
            {
                Thread.Sleep(1000);
                //在 HE 代理期间输出 "In while loop!"
                //DS "In while loop!"
            }
  
            for (int i = 0; i < args.Length; i++)
            {
                 Console.WriteLine(args[i]);
            }
  
            //防止 while 退出后直接关闭主线程
            //Once (这句 `//Once` 可以不写,HE 有针对 “Console.Read” 的末尾阻塞检测)
            Console.ReadKey();

        }

        //方法体中的参数操作对应 Main(string[] args) 中的 args,  
        //热执行时,Main 将接收到 "参数11",“参数2”,“参数23”
        //非必要,可以不写
        public static void ProxyMainArguments()
        {
            HEProxy.AppendArgs("参数11");
            HEProxy.AppendArgs("参数2");
            HEProxy.AppendArgs("参数23");
        }
}

这段代码是 HE 最原始的代码。

SG 加持(.NET5.0及以上版本)

SG 主要是减少了 HE 初始化的一些操作。而一些需要手动传递的 cancel/dispose 实例仍然需要手动传递给 HE。

简单案例

  1. 引入 SG 包:
    DotNetCore.Natasha.CSharp.HotExecutor.Wrapper
internal class Program
{

    static void Main(string[] args)
    {
        for (int i = 0; i < args.Length; i++)
        {
            //DS args[i]
        }

        //这里仍然需要手动将 canceltoken 传递给 HE
        CancellationTokenSource source = new();
        source.ToHotExecutor();

        while (!source.IsCancellationRequested)
        {
            Thread.Sleep(1000);
            //DS "In while loop!"
        }
     
        //防止 while 退出后直接关闭主线程
        Console.ReadKey();
    }
    public static void ProxyMainArguments()
    {
        HEProxy.AppendArgs("参数1");
        HEProxy.AppendArgs("参数2");
        HEProxy.AppendArgs("参数3");
        HEProxy.AppendArgs("参数4");
    }
}

代理新 Asp.net Core

HE 目前不能代理 MVC 项目和老版的 API 项目。

public class Program
{
    public static void Main(string[] args)
    {

        //HE:Async
        var builder = WebApplication.CreateBuilder(args);
        builder.Services.AddAuthorization();
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();
        var app = builder.Build();
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }


        //将 APP 添加到 HE 中,以便在下一次编译中释放该对象。
        app.AsyncToHotExecutor();

        //更改以下的值,保存文件,会触发 HE 创建新的 WebApplicationBuilder
        var summaries = new[]
        {
            "Freezing441", "Bracing"
        };


        app.MapGet("/weatherforecast", (HttpContext httpContext) =>
        {
            var forecast = Enumerable.Range(1, 5).Select(index =>
                new WeatherForecast
                {
                    Date = DateTime.Now.AddDays(index),
                    TemperatureC = Random.Shared.Next(-20, 55),
                    Summary = summaries[Random.Shared.Next(summaries.Length)]
                })
                .ToArray();
            return forecast;
        })
        .WithName("GetWeatherForecast");

        app.Run();
    }
}

其他项目支持

截至目前而言, HE 对 Winform 的支持不是很好,WPF 的很难代理,时间和精力有限,不会深入去研究了。

鸣谢

感谢
九哥
的支持。

结尾

遇到问题可以到
Natasha Issue 区
提出反馈。

在经过长时间对WxPython的深入研究,并对其构建项目有深入的了解,以及完成对基础框架的完整改写后,终于在代码生成工具完全整合了基于 Python 跨平台方案项目的代码快速生成了,包括基于FastApi 的后端Web API项目,以及前端的WxPython前端界面项目。本篇随笔主要介绍跨平台WxPython项目的前端代码生成内容。

1、代码生成工具的整合处理

在前面随笔《
基于SqlAlchemy+Pydantic+FastApi的Python开发框架
》中提到过,对于基于FastApi的项目我们已经使用自家的代码生成工具快速进行代码的生成,FastApi+SqlAlchemy+Python后端代码生成,可以生成模型、Schema对象、CRUD封装类、Endpoint路由类等。

本次在基于对WxPython的深入研究,并对我基础框架的内容进行全部的改写后,整理了基于WxPython的项目代码生成,其中包括生成列表界面、新增/编辑界面、WebApi调用类、实体信息等。对WxPython跨平台项目界面的有兴趣的可以参考随笔《
分享一个纯Python开发的系统程序,基于VSCode + WxPython开发的跨平台应用系统
》。

至此,整个Python的项目前后端串联起来,完成了一个完整的项目了。

代码生成工具可以到地址下载:
https://www.iqidi.com/database2sharp.htm
,使用代码生成工具来快速开发项目代码,有很多好处。

减少重复工作
:自动生成常用代码(如数据模型、CRUD 操作、API 接口等),减少手动编写的时间。

专注于核心逻辑
:开发者可以将时间集中在业务逻辑和复杂问题的解决上,而非基础代码的编写。

模块化和规范化
:生成代码一般遵循既定的架构和风格,便于后续维护和扩展。

初学者友好
:新手开发者可以通过代码生成工具快速上手,了解项目结构和基础代码。

代码生成工具在提升开发效率、降低出错率、标准化代码方面具有显著优势,尤其在重复性工作较多或团队合作时尤为适用。

1)后端项目代码的生成

Python + FastAPI项目是一个Web API的项目,为各个前端提供接口的后端项目,其界面自动整合Swagger的文档界面,如下所示。

在代码生成工具打开数据库列表后,右键菜单可以选择生成对应的Python+FastApi后端项目,如下界面所示。

选中相关的表后,一键可以生成各层的类文件,其中包括最为繁琐的Model映射类信息。如下是生成的相关类的界面效果。

2) WxPython前端项目代码生成

在经过对WxPython的深入研究后,并依据改造过的项目结构,整合在代码生成工具中,对项目的代码,包括列表界面,编辑界面、API调用类、DTO实体信息等进行统一的生成。

选择相关的数据表后,一键生成相关的代码,如下所示。

1)列表界面和继承关系

列表界面继承基类,从而可以大幅度的利用相应的规则和实现。

如对于两个例子窗体:系统类型定义,客户信息,其中传如对应的DTO信息和参数即可。

因为常规的列表界面一般分为查询区、列表界面展示区和分页信息区,我们把它分为两个主要的部分,如下界面所示。

当然如果有树形列表的,也整合在基类窗体中实现控制逻辑,具体实现放在子类处理即可。

同时,在树列表或者表格数据控件支持右键弹出菜单处理,包括常规的新增、编辑、删除、复制、刷新等常规功能,如果需要更多业务模块的功能,整合在右键菜单中,在窗体子类中重写某些只定义函数即可实现。

对于列表界面,生成的代码如下所示(以客户信息表为例):

代码工具最大程度的提供常规方法的处理,如果需要特殊的操作,如在查询框中的条件,那么需要根据需要修改一下即可。

    def CreateConditions(self, pane: wx.Window) ->List[wx.Window]:"""创建折叠面板中的查询条件输入框控件"""
        #创建控件,不用管布局,交给CreateConditionsWithSizer控制逻辑
        #默认的FlexGridSizer为4*2=8列,每列间隔5pxself.txtName=ctrl.MyTextCtrl(pane)
self.txtAge
=ctrl.MyNumericRange(pane)
self.txtCustomerType
= ctrl.MyComboBox(pane, style=wx.CB_READONLY)

util
=ControlUtil(pane)
util.add_control(
"姓名:", self.txtName)
util.add_control(
"年龄:", self.txtAge)
util.add_control(
"客户类型:", self.txtCustomerType) #测试数据类型绑定 return util.get_controls()

基本上就是不变的内容和规则,由基类处理,变化的内容,由子类来具体化即可。对于新增、编辑、删除的操作,我们根据表的不同,生成子类实现代码,一般不用修改。

    async def OnAdd(self, event: wx.Event) ->None:"""子类重写-打开新增对话框"""dlg=FrmCustomerEdit(self)if await AsyncShowDialogModal(dlg) ==wx.ID_OK:#新增成功,刷新表格
await self.update_grid()
dlg.Destroy()

如果需要再查询框中初始化下拉列表的内容,我们重写初始化字典函数即可。

    async definit_dict_items(self):"""初始化字典数据-子类重写"""await self.txtCustomerType.bind_dictType("客户类型")

通过上面我们构建的基类处理,以及提供一个界面辅助类来处理,可简化很多不必要的代码,而且还很灵活的控制布局处理,非常方便。

2)编辑/新增界面继承关系

继承基类编辑对话框(通常用于创建模态对话框或自定义窗口的基类)有以下优点:

  • 共享通用功能
    :将所有对话框的共同行为(如按钮布局、事件处理、数据校验逻辑等)封装在基类中,子类可以直接继承使用,无需重复实现。
  • 减少冗余代码
    :对话框的通用结构只需在基类中实现一次,后续的功能扩展只需通过继承来实现,减少代码重复。
  • 统一界面风格
    :基类可以预定义窗口的样式和布局,确保项目中所有对话框的界面一致。
  • 快速定制功能
    :子类仅需实现或覆盖特定方法,即可快速实现自定义对话框的功能。

继承基类编辑对话框能够提高代码复用性、开发效率和维护性,特别适合在复杂系统中管理多个相似对话框。通过合理设计基类,开发者可以显著减少重复代码,实现更灵活的功能扩展。

常规的对话框中,业务表编码规则的新增、编辑界面如下所示。

当然,我们也可以增加更多的定制功能,稍作调整可以增加多页的功能。

一般我们在编辑框中,或者列表窗体中,我们都可能有树形列表的情况,我们提供标准的处理方法,用于对这些内容进行修改。

对于编辑界面来说,我们继承父类后,子类重写一些实现函数即可实现弹性化的处理了。

上面这些函数就是各司其职,对界面的内容处理,显示编辑数据,校验输入,初始化字典、加载信息,保存对象信息,都是我们在编辑框中需要处理到的内容,我们根据不同的需求进行修改即可。

生成的界面代码中,对于输入控件的显示放在add_controls函数里面,如下代码所示。

    def add_controls(self, panel: wx.Window) ->wx.GridBagSizer:#创建一个 GridBagSizer
        grid_sizer = wx.GridBagSizer(5, 5)  #行间距和列间距为 5
        util = GridBagUtil(panel, grid_sizer, 2)  #构建工具类,布局为2列
self.txtName= ctrl.MyTextCtrl(panel, placeholder="客户姓名")
self.txtAge
=wx.SpinCtrl(panel)
self.txtCreateTime
=ctrl.MyDatePickerCtrl(panel)
self.txtCreateTime.Disable()

self.txtNote
= ctrl.MyTextCtrl(panel, placeholder="备注", style=wx.TE_MULTILINE)

util.add_control(
"客户姓名", self.txtName, is_expand=True)
util.add_control(
"年龄", self.txtAge, is_expand=True)
util.add_control(
"创建日期", self.txtCreateTime, is_expand=True)
util.add_control(
"备注", self.txtNote, is_expand=True, is_span=True)#让控件跟随窗口拉伸 grid_sizer.AddGrowableCol(1) #允许第n列拉伸 return grid_sizer

我们通过代码

util = GridBagUtil(panel, grid_sizer, 2) # 构建工具类,布局为2列

构建了一个辅助类来处理布局的,添加的时候不用管布局,大概知道是几列的即可。

在Windows下,客户信息的编辑界面如下所示。

如果我们需要修改为双排的,那么修改下:

    def add_controls(self, panel: wx.Window) ->wx.GridBagSizer:#创建一个 GridBagSizer
        grid_sizer = wx.GridBagSizer(5, 5)  #行间距和列间距为 5
        util = GridBagUtil(panel, grid_sizer, 4)  #构建工具类,布局为4列
self.txtName= ctrl.MyTextCtrl(panel, placeholder="客户姓名")
self.txtAge
=wx.SpinCtrl(panel)
self.txtCreateTime
=ctrl.MyDatePickerCtrl(panel)
self.txtCreateTime.Disable()

self.txtNote
= ctrl.MyTextCtrl(panel, placeholder="备注", style=wx.TE_MULTILINE)

util.add_control(
"客户姓名", self.txtName, is_expand=True)
util.add_control(
"年龄", self.txtAge, is_expand=True)
util.add_control(
"创建日期", self.txtCreateTime, is_span=True)
util.add_control(
"备注", self.txtNote, is_expand=True, is_span=True, is_stretch=True)#让控件跟随窗口拉伸 grid_sizer.AddGrowableCol(1) #允许第n列拉伸 grid_sizer.AddGrowableCol(3) #允许第n列拉伸 return grid_sizer

界面效果如下所示。

稍作修改即可重新布局,非常方便。

利用代码生成工具,可以很好的利用现有基类的关系生成相关的代码,包括界面代码,对Web API的调用代码也是一样,我们只需要做好继承关系,就具有基类一些的CRUD接口了。

例如对于客户信息的API接口封装类,我们只需要增加一些个性化的自定义函数即可,默认对应基类有相关的CRUD接口。

classCustomer(BaseApi[CustomerDto]):"""客户信息--API接口类"""api_name= "customer"

    def __init__(self):
super().
__init__(self.api_name, CustomerDto)

async
def exist(self, name: str = None, id: str = None) ->bool:"""判断记录是否存在,如果指定ID,则判断ID不等于当前ID的记录是否存在"""url= f"{self.base_url}/exist"params= {"name": name, "id": id}
data
= await self.client.get(url, params=params)

res
=AjaxResponse[bool].model_validate(data)return res.result if res.success elseFalse

async
def get_by_name(self, name: str) ->CustomerDto:"""根据名称获取客户信息"""url= f"{self.base_url}/by-name"params= {"name": name}
data
= await self.client.get(url, params=params)

res
=AjaxResponse[CustomerDto].model_validate(data)return res.result if res.success elseNone#构建一个业务逻辑实例,方便调用 api_customer = Customer()

这个基类的函数,和后端的控制器接口一一对应。

利用代码生成工具,开发项目事半功倍,这就是工具的力量和魅力。

8.2.1  操作系统和多线程

要在应用程序中实现多线程,必须有操作系统的支持。Linux 32位或64位操作系统对应用程序提供了多线程的支持,所以Windows NT/2000/XP/7/8/10是多线程操作系统。根据进程与线程的支持情况,可以把操作系统大致分为如下几类:

(1)单进程、单线程,MS-DOS大致是这种操作系统。

(2)多进程、单线程,多数UNIX(及类UNIX的Linux)是这种操作系统。

(3)多进程、多线程,Win32(Windows NT/2000/XP/7/8/10等)、Solaris 2.x和OS/2都是这种操作系统。

(4)单进程、多线程,VxWorks是这种操作系统。

具体到Linux C++的开发环境,它提供了一套POSIX API函数来管理线程,用户既可以直接使用这些POSIX API函数,也可以使用C++自带的线程类。作为一名Linux C++开发者,这两者都应该会使用,因为在Linux C++程序中,这两种方式都有可能出现。

8.2.2  线程的基本概念

现代操作系统大多支持多线程概念,每个进程中至少有一个线程,所以即使没有使用多线程编程技术,进程也含有一个主线程,所以也可以说,CPU中执行的是线程,线程是程序的最小执行单位,是操作系统分配CPU时间的最小实体。一个进程的执行说到底是从主线程开始的,如果需要,可以在程序任何地方开辟新的线程,其他线程都是由主线程创建的。一个进程正在运行,也可以说是一个进程中的某个线程正在运行。一个进程的所有线程共享该进程的公共资源,比如虚拟地址空间、全局变量等。每个线程也可以拥有自己私有的资源,如堆栈、在堆栈中定义的静态变量和动态变量、CPU寄存器的状态等。

线程总是在某个进程环境中创建的,并且会在这个进程内部销毁。线程和进程的关系是:线程是属于进程的,线程运行在进程空间内,同一进程所产生的线程共享同一内存空间,当进程退出时,该进程所产生的线程都会被强制退出并清除。线程可与属于同一进程的其他线程共享进程所拥有的全部资源,但是其本身基本上不拥有系统资源,只拥有一点在运行中必不可少的信息(如程序计数器、一组寄存器和线程栈,线程栈用于维护线程在执行代码时需要的所有函数参数和局部变量)。

相对于进程来说,线程所占用的资源更少。比如创建进程,系统要为它分配很大的私有空间,占用的资源较多;而对于多线程程序来说,由于多个线程共享一个进程地址空间,因此占用的资源较少。此外,进程间切换时,需要交换整个地址空间,而线程间切换时,只是切换线程的上下文环境,因此效率更高。在操作系统中引入线程带来的主要好处是:

(1)在进程内创建、终止线程比创建、终止进程要快。

(2)同一进程内线程间的切换比进程间的切换要快,尤其是用户级线程间的切换。

(3)每个进程具有独立的地址空间,而该进程内的所有线程共享该地址空间,因此线程的出现可以解决父子进程模型中子进程必须复制父进程地址空间的问题。

(4)线程对解决客户/服务器模型非常有效。

虽然多线程给应用开发带来了不少好处,但并不是所有情况下都要去使用多线程,要具体问题具体分析。通常在下列情况下可以考虑使用多线程:

(1)应用程序中的各任务相对独立。

(2)某些任务耗时较多。

(3)各任务有不同的优先级。

(4)一些实时系统应用。

值得注意的是,一个进程中的所有线程共享它们父进程的变量,但同时每个线程可以拥有自己的变量。

8.2.3  线程的状态

一个线程在从创建到结束这一生命周期中,总是处于下面4个状态中的一个。

1)就绪态

线程能够运行的条件已经满足,只是在等待处理器(处理器要根据调度策略来把就绪态的线程调度到处理器中运行)。处于就绪态的原因可能是线程刚刚被创建(刚创建的线程不一定马上运行,一般先处于就绪态),也可能是刚刚从阻塞状态中恢复,还可能是因被其他线程抢占而处于就绪态。

2)运行态

运行态表示线程正在处理器中运行,正占用着处理器。

3)阻塞态

由于在等待处理器之外的其他条件而无法运行的状态叫作阻塞态。这里的其他条件包括I/O操作、互斥锁的释放、条件变量的改变等。

4)终止态

终止态就是线程的线程函数运行结束或被其他线程取消后处于的状态。处于终止态的线程虽然已经结束了,但它所占资源还没有被回收,而且还可以被重新复活。我们不应该长时间让线程处于这种状态,线程处于终止态后应该及时进行资源回收,下面会讲到如何回收。

8.2.4  线程函数

线程函数就是线程创建后进入运行态后要执行的函数。执行线程说到底就是执行线程函数。这个函数是我们自定义的,然后在创建线程时把我们的函数作为参数传入线程创建函数。

同理,中断线程的执行就是中断线程函数的执行,以后再恢复线程的时候,就会在前面线程函数暂停的地方继续执行下面的代码。结束线程也就不再运行线程函数。线程的函数可以是一个全局函数或类的静态函数,比如在POSIX线程库中,它通常这样声明:

void *ThreadProc (void *arg);

其中,参数arg指向要传给线程的数据,这个参数是在创建线程的时候作为参数传入线程创建函数中的。函数的返回值应该表示线程函数运行的结果:成功还是失败。注意函数名ThreadProc可以是自定义的函数名,这个函数是用户自己先定义好,然后由系统来调用。

8.2.5  线程标识

既然句柄是用来标识线程对象的,那线程本身用什么来标识呢?在创建线程的时候,系统会为线程分配唯一的ID作为线程的标识,这个ID从线程创建开始就存在,一直伴随着线程的结束才消失。线程结束后,该ID就自动不存在,我们不需要去显式清除它。

通常线程创建成功后会返回一个线程ID。

8.2.6  C++多线程开发的两种方式

在Linux C++开发环境中,通常有两种方式来开发多线程程序:一种是利用POSIX多线程API函数来开发多线程程序,另一种是利用C++自带线程类来开发多线程程序。这两种方式各有利弊。前一种方法比较传统,后一种方法比较新,是C++11推出的方法。为何C++程序员也要熟悉POSIX多线程开发呢?这是因为C++11以前,在C++里面使用多线程一般都是利用POSIX多线程API,或者把POSIX多线程API封装成类,再在公司内部供大家使用。因此,一些老项目都是和POSIX多线程库相关的,这也使得我们必须熟悉它,因为很可能进入公司后会要求维护以前的程序代码。而C++自带线程类很可能在以后开发新的项目时会用到。总之,技多不压身。

本文节选自《Linux C与C++一线开发实践(第2版)》,获出版社和作者授权发布。