2024年1月

.NET8.0 与 动态编译

Hello 各位小伙伴,我于 2024年1月10日 发布了
Natasha
一个全新的里程碑版本 v8.0,对于老用户而言,此次发布版本号跨度较大,是因为我决定使用新的版本号计划,主版本号将随 Runtime 版本号的增加而增加。

浅谈 .NET8.0

在 .NET8.0 Runtime 方向的深度解析文章出来之前,八卦了一些新闻,例如前阶段的文档收费风波,就那一段时间我觉得粥里的乌江榨菜都不香了;又目睹马某某说微软不开源,这损犊子玩意,以马祭码吧。
.NET8.0 更新的东西真的太多了,仅作为八卦聊聊,比如略低分配的异步状态机,ConfigureAwait 的改进,Win 线程池与托管线程池的切换,减少了 GCHandle 滥用的 Socket,向量运算提升字符操作的性能等,这么串起来,整个网络通信技术栈均有受益,照这么下去,.NET10 时 Asp.net Core 的相关性能测试跑第一也不是不可能的了。话说回来,其中官方比较看重的一项提升 “SearchValues”。官方引入 "SearchValues.Create()" API 根据要查找的字符来返回不同的策略实现,本质上是为了缓解 O(M*N) 的性能问题,定位算法策略多达 10 种,包括简单的"四八取值定位",纯 Ascii 字符定位,范围定位,向量运算定位,位图网格定位等(算法名都没找到,字面意思帮助理解), 除了向量算法属于普通应用盲区,其他算法都蛮好理解的,向量算法可以参考时总写过的一些
博客

动态编译 与 Natasha

在介绍新版之前,必须让新来者了解动态编译相关的知识,动态编译在 .NET 生态中一直扮演着重要角色,例如
Dapper
,
Json.net
,
AutoMapper
,
EFCore
,
动态编译版 Razor
,
Orleans
等类库中或多或少都存在动态编译相关的代码,在 Source Generators 出现之前 [运行时动态] 一直是建设 .NET 生态的重要技能,但繁重的 IL 以及 Expression 代码无疑不给开发者带来巨大的维护和升级成本,不仅如此,在执行性能上, Emit 方法的执行性性能只能趋近于原生编译,并不能超过(这里纠正一下看到某篇文章提到 emit 要比原代代码编译执行快的观点), 然而 SG 以及 AOT 兼容性方案的出现不仅解决了一些动态代码性能上的问题,还让 .NET 生态顺利开展出另个分支,即 AOT 生态。
说到 AOT, 在启动耗时过长,内存拮据,服务端对发布包大小有严格限制这三类场景中,AOT 如今已经成为开发界所热衷的方案。.NET8.0 中更加全面的支持了 AOT,Asp.net Core 推出了 WebApplication.CreateSlimBuilder() 作为 Web 的 AOT 方案,在 .NET8.0 发布后,除了官方类库,应该属老叶的 FreeSql 在动静兼容上做的是又快又稳了。在 .NET8.0 之前更早实现兼容方案的,不得不提九哥的
WebApiClient
。即使有这些前车之鉴,Natasha 也无法参考。 Natasha 作为动态编译类库不得不站在 .NET8.0 的另一个重大技术特性之上,即 .NET8.0 默认开启的动态 PGO 优化。AOT 并不能作为最佳的性能选择方案,相反运行时的最简动态策略以及对机器码的动态优化更加合适性能敏感场景。在此之前我也曾仔细想过 [编译时动态] 的适用场景有多么广泛,能否取代 [运行时动态],结论是不管 [编译时动态] 有多么优秀也无法取代 [运行时动态],彻底放弃 [运行时动态] 的做法也是十分欠考虑的,而且在纯动态业务的场景中 SG 以及 AOT 方案是十分无力的,此时需要一个 [运行时动态] 方案来达到业务目标,这里推荐使用
Natasha
.

那么使用新版 Natasha 来完成 [运行时动态] 的相关功能有什么好处呢?
答案是高效快速、轻量方便,智能省心.
Natasha 是基于 Roslyn 开发的,它允许将 C# 脚本代码转换成程序集,并享受编译优化、运行时优化带来的性能提升;在易用性上新版 Natasha 组件层次分明,API 规范可查,在保证灵活性的同时还封装了很多细节;在扩展性上 Natasha 每次更新都会尽量挖掘 Roslyn 的隐藏功能给大家使用;在封装粒度上,Natasha 自有一套减少用户编译成本的方案,让更多的细节变得透明,接下来可以看一看新版 Natasha 都有哪些变化。

Natasha 项目地址:
https://github.com/dotnetcore/Natasha


Natasha8.0 的新颜

开发相关

Natasha 应用用了前一篇文章提到的 CI/CD Runner,并加以实战改造,在 PR 管理,ISSUE 管理等管道功能上得到了便利的支持。

此版本我们有三个大方向上的编码任务,分别是功能性上的轻量化路线,扩展性上的动态方法使用率统计,以及兼容性上对 standard2.0 的支持。

Natasha 从本次更新起,停止了对非 LTS .NET 版本进行 UT 测试,在开发者使用非 LTS 版本 Runtime 时小概率可会出现意外情况,若遇到可提交
issue


一. API 命名规范

  • With 系列 API:
    带有关闭、排除、枚举附加值等条件状态开关时使用的 API。 例如:
    WithCombineUsingCode

    WithoutCombineUsingCode
    ,
    WithHighVersionDependency

    WithLowVersionDependency

    WithDebugCompile

    WithReleaseCompile

    WithFileOutput
    等,又例如编译选项的 API 都是作为附加条件赋给选项的,因此都由 With 开头(注:与 Roslyn 风格不同,With 方法不返回新对象).

  • Set 系列 API:
    属单向赋值类 API, 例如:
    SetDllFilePath

    SetReferencesFilter
    等.

  • Config 系列 API:
    具有对主类中,某重要组件的额外配置,通常是各类 options 操作的 API, 例如:
    ConfigCompilerOption

    ConfigSyntaxOptions
    等.

  • 特殊功能 API:
    此类 API 需要非常独立且明确的功能,常用而显眼,例如
    UseRandomDomain

    UseSmartMode

    OutputAsFullAssembly

    GetAssembly
    等显眼包 API.


二. 性能提升

新版 Natasha 使用并发的方式将两种预热方法(引用/实现程序集预热)的执行时间从 .NET8.0 实验环境的 2-4s 降低到了 0.700 -1 s 左右;预热的内存涨幅从 60-70M 降到 30-40M。
新版 Natasha 允许开发者灵活管理元数据覆盖策略,比如

  • 合并共享域及当前域的元数据.
  • 仅使用当前域元数据.
  • 仅使用指定的元数据等.

这使得 Natasha 可以支持自定义轻量化编译,在实验案例中轻量化编译比预热编译节省了约 15M 左右的内存。

以下是引用程序集与实现程序集的预热耗时统计截图

引用程序集预热

实现程序集预热

以下是引用程序集与实现程序集的预热内存统计截图

引用程序集预热

实现程序集预热


三. Standard2.0 兼容方案

新版编译单元的依赖项变为了
Standard2.0
, 编译单元项目移除了
System.Reflection.MetadataLoadContext
依赖包, Natasha 将直接从文件中提取元数据,避免一些繁琐的加载操作,另外我们还移除了对
DotNetCore.Natasha.Domain
的依赖,尽管域对于 Natasha 来说十分重要,域作为 Runtime 的重要特性,它严重牵制着 Natasha 的兼容性,为此我对 Natasha 的框架进行了重新设计,将域以及一些运行时方法交由第三方去实现,而 Natasha 只保留和调用 Standard2.0 的接口,这两个接口为
DotNetCore.Natasha.DynamicLoad.Base
包中的
INatashaDynamicLoadContextBase

INatashaDynamicLoadContextCreator
,开发者可以根据两个接口自行实现域功能,但这里 Core3.0 以上版本我推荐使用
DotNetCore.Natasha.CSharp.Compiler.Domain
Natasha 官方实现的域功能,该包继承自
DotNetCore.Natasha.Domain
, 这是一个功能强大且稳定的 .NET 域实现包。

当然 Natasha 的使用方式也发生了一些变化:

//首先向 Natasha 加载上下文中注入域创建者实现类 NatashaDomainCreator
//NatashaDomainCreator 来自包 DotNetCore.Natasha.CSharp.Compiler.Domain,实现了 INatashaDynamicLoadContextCreator 接口
NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();

//若需要预热,也可以直接使用泛型预热,泛型预热将自动调用 NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
NatashaManagement.Preheating<NatashaDomainCreator>(false, false);

与此同时,新版 Natasha 解耦了编译单元及模板,部分开发者在使用 Natasha 时习惯自己构建脚本代码,而不需要 Natasha 本身模板的参与,为此我们解耦了模板与编译单元的相关代码,现在您可以引用
DotNetCore.Natasha.CSharp.Template.Core
来使用模板的相关功能,或者单独引用
DotNetCore.Natasha.CSharp.Compiler
仅使用编译单元的功能。

对于运行时目前区分了 "Core" 和 "Framework" 版本,"Core" 相关的代码将继续维护着,而与 "Framework" 相关的代码任务已经停止,从去年年底我已无精力去做 Framework 的兼容工作,经济来源对于 2024 年的我来说是个巨大难题,更多的思考与尝试都将围绕着如何维持生活来展开,但是 Natasha 会接受 PR,接受开源贡献者的代码。如果您不想使用上一版本的 Framework 实现,不介意您联系我进行有偿定制,这里也希望诸各位的公司项目早日脱离 Framework 苦海。


四. 域的改进

提到动态编译不得不说的一个前提就是“域”,再次强调这里所说的域是 .NETCore3.0 + 版本的 ALC (程序集加载上下文),Natasha 对 ALC 进行了较全面的封装,您可以单独引用
Natasha.Domain
以便进行插件加载等操作,
本次更新我对域操作进行了修正与补充:

  1. Natasha 实现的 ALC 将避开依赖程序集的重复加载。
  2. 我发现之前的代码中,在共享域加载为主的逻辑中,ALC 默认将程序集交由共享域处理,共享域处理不过接由当前域处理,新版本在确定共享域存在程序集的情况下,将直接返回共享域的程序集,无需另外处理。
  3. 在依赖程序集被排除的情况下,如果该程序集在共享域中存在,将返回共享域的程序集。

新增
Natasha.CSharp.Compiler.Domain
项目继承 Natasha.Domain 项目并实现基础编译接口。

使用域加载插件

domain1.LoadPluginXXX(file)

在 Natasha 中使用加载插件,并加载插件元数据及 Using Code.

var loadContext = DomainManagement.Random();
//或
var loadContext = (new AssemblyCSharpBuilder().UseRandomDomain()).LoadContext;

var domain = (NatashaDomain)(loadContext.Domain);

//排除基类接口,否则你反射出来的方法只能在当前域编码使用(更多详情请学习微软官方关于插件的相关知识)。
Func<AssemblyName, bool>? excludeInterfaceBase= item => item.Name!.Contains("IPluginBase");

//获取插件程序集.
var assembly = domain.LoadPluginWithHighDependency(file, excludeInterfaceBase);
//添加元数据以及 using code.
loadContext.AddReferenceAndUsingCode(assembly, excludeInterfaceBase);


五. 元数据管理优化

元数据以及 using code 对于 Roslyn 编译来说属于重点依赖对象,新版 Natasha 增加了 NatashaLoadContext 来管理元数据,在 vs 开发过程中,由于动态脚本没有智能提示和隐式 using 覆盖,因此早期 Natasha 推出了透明模式,让元数据管理变得透明不可见,预热过程将缓存元数据和 using code,使用时自动覆盖元数据引用以及 using code。对于 using code 的全覆盖,类似于近期 vs 推出的隐式 usings 的功能,Natasha 还为编译单元增加了语义过滤器的支持,以便自动处理编译诊断。
同时 NatashaLoadContext 还支持解析实现程序集和引用程序集,早期 Natasha 仅在预热时会缓存引用程序集的元数据,而如今,Natasha 不仅支持两种程序集的预热还支持在不预热的情况下允许开发者自管理元数据。

 /// <summary>
 /// 预热方法,调用此方法之前需要调用 RegistDomainCreator<TCreatorT> 确保域的创建
 /// </summary>
 /// <param name="excludeReferencesFunc"></param>
 /// <param name="useRuntimeUsing">是否使用实现程序集的 using code</param>
 /// <param name="useRuntimeReference">是否使用实现程序集的元数据</param>
 /// <param name="useFileCache">是否使用 using 缓存</param>
 public static void Preheating(
     Func<AssemblyName?, string?, bool>? excludeReferencesFunc,
     bool useRuntimeUsing = false, 
     bool useRuntimeReference = false,
     bool useFileCache = false);

预热案例1:
自动注入域实现,从内存中的 [实现程序集] 中提取元数据和 using code.

NatashaManagement.Preheating<NatashaDomainCreator>(true, true);

预热案例2:
手动注入域实现, 从 refs 文件夹下的 [引用程序集] 中提取元数据和 using code. (需提前引入 DotNetCore.Compile.Environment 包).

NatashaManagement.RegistDomainCreator<NatashaDomainCreator>();
NatashaManagement.Preheating(false, false);

预热案例3:
自动注入域实现,从 refs 文件夹下的 [引用程序集] 中提取 using code. (需提前引入 DotNetCore.Compile.Environment 包),从内存中的[实现程序集]中提取元数据, 此种方法一旦运行过一次,就会产生 using 缓存文件,此时即使删除 refs 文件夹程序仍会正常工作.

NatashaManagement.Preheating<NatashaDomainCreator>(false, true, true);


六. 多种编译模式

1. 智能编译模式

使用智能编译模式,编译单元 AssemblyCSharpBuilder 将默认合并 共享加载上下文(NatashaLoadContext.DefaultContext) 和 当前上下文(builder.LoadContext) 的元数据以及 using code,并自动开启语义过滤,如下是较完整的使用代码:

1.若不使用内存程序集,则需要引入
DotNetCore.Compile.Environment
来输出引用程序集。
2.预热并注册域实现。
3.启用智能模式编码。

NatashaManagement.Preheating<NatashaDomainCreator>();
AssemblyCSharpBuilder builder = new();
var myAssembly = builder
    .UseRandomDomain()
    .UseSmartMode() //启用智能模式
    .Add("public class A{ }")
    .GetAssembly();

2. 轻便编译模式

新版 Natasha 允许开发者使用编译单元进行轻量级编译,如果您只是想创建一个计算表达式或者一个简单逻辑的映射,建议您使用编译单元的轻便模式进行动态编译。轻便模式不会合并主域的元数据和 using 代码,只会使用当前域的,并且不会触发语义过滤。


AssemblyCSharpBuilder builder = new();
builder
    .UseRandomDomain()
    .UseSimpleMode() //启用轻便模式
    .ConfigLoadContext(ldc=> ldc
     .AddReferenceAndUsingCode(typeof(Math).Assembly)
     .AddReferenceAndUsingCode(typeof(MathF))
     .AddReferenceAndUsingCode(typeof(object)))
    .Add("public static class A{ public static int Test(int a, int b){ return a+b; }  }");

var func = builder
    .GetAssembly()
    .GetDelegateFromShortName<Func<int,int,int>>("A", "Test");

func(1,2);

3. 自定义编译模式

AssemblyCSharpBuilder builder = new();
builder
    .UseRandomDomain()
    .WithSpecifiedReferences(元数据集合)
    .WithoutCombineUsingCode()
    .WithReleaseCompile()
    .Add("using System.Math; using System; public static class A{ public static int Test(int a, int b){ return a+b; }  }");

其中
WithSpecifiedReferences
方法允许您传入引用集合,例如 Roslyn 成员提供的
Basic.Reference.Assemblies
引用程序集包。由于案例中指定了
WithoutCombineUsingCode
方法,该方法将不会自动覆盖 using code, 因此脚本中需要手动添加 using code例如
using System;


七. 动态调试

新版本 Natasha 允许在编译单元在指定 Debug 编译模式后,使用 VS 进入到方法内进行调试.
同时这里介绍一种隐藏的 Release 模式,该模式允许在生成程序集时携带有 Debug 相关的信息,之前被定义为 Debug 的 Plus 版本/可调试的 Release 模式,还可以增加您反编译时的可读性(这个功能 Roslyn 随后几个版本可能会加入到优化级别的枚举中暴露给开发者)。
也许我们已经在 VS 中体验过了?这个功能后续我会继续跟进测试研究。

//调试信息写入文件,原始的写入方式,对 Win 平台支持良好
builder.WithDebugCompile(item=>item.WriteToFileOriginal())
//调试信息写入文件,兼容性写入方式
builder.WithDebugCompile(item=>item.WriteToFile())
//调试信息整合到程序集中
builder.WithDebugCompile(item=>item.WriteToAssembly())

//Release 发布无法进行调试
builder.WithReleaseCompile()
//Release 模式将携带 debugInfo 一起输出
builder.WithFullReleaseCompile()

案例

AssemblyCSharpBuilder builder = new();
builder
  .UseRandomDomain()
  .UseSimpleMode()
  .WithDebugCompile(item => item.WriteToAssembly())
  .ConfigLoadContext(ldc=> ldc
    .AddReferenceAndUsingCode(typeof(object).Assembly)
    .AddReferenceAndUsingCode(typeof(Math).Assembly)
    .AddReferenceAndUsingCode(typeof(MathF).Assembly));

builder.Add(@"
namespace MyNamespace{

    public class A{

        public static int N1 = 10;
        public static float N2 = 1.2F; 
        public static double N3 = 3.44;
        private static short N4 = 0;

        public static object Invoke(){
            int[] a = [1,2,3];
            return N1 + MathF.Log10((float)Math.Sqrt(MathF.Sqrt(N2) + Math.Tan(N3)));
        }
    }
}
");
var method = builder
  .GetAssembly()
  .GetDelegateFromShortName<Func<object>>("A", "Invoke");

//断点调试此行代码
var result = method(); 


八. 程序集输出

Natasha 8.0 版本允许您在动态编译完成后输出完整程序集或引用程序集,注意这里并没有进行什么智能判断,需要您手动控制行为,域加载引用程序集会引发异常。请看以下例子来达到仅输出的目的。

//编译结果为引用程序集,且写入文件,且不会加载到域。
builder
  .OutputAsRefAssembly();
  .WithFileOutput()
  .WithoutInjectToDomain();

注: 如果您希望把 Natasha 作为一个插件生产器,那么很遗憾,目前它并不能像 VS 编辑器那样输出完整的依赖以及依赖文件。


九. 输出文件

Natasha 支持 dll/pdb/xml 文件输出,其中 xml 存储了程序集注释相关的信息。参考 API

//该方法将使程序集输出到默认文件夹下的 dll/pdb/xml 文件中
//可传入一个文件夹路径
//可以传入三个文件的路径
builder.WithFileOutput(string dllFilePath, string? pdbFilePath = null, string? commentFilePath = null)
builder.WithFileOutput(string? folder = null);
//分离的 API
builder.SetDllFilePath/SetPdbFilePath/SetCommentFilePath();


周边扩展

一. 动态程序集方法使用率统计

众所周知,单元测试中测试方法覆盖率统计通常使用 VS 自带的工具进行静态统计,还有 CLI 工具,这里 Natasha 推出一种新的扩展,允许开发者动态的统计[由 Natasha 生成的动态程序集]中的[方法]使用情况,目前已通过测试,并发布了第一个扩展包。此项技术还需要搜集需求和建议,因此我们的
ISSUE
被设置为 phase-done,欢迎大家留言提需求和建议。

使用方法:

  1. 引入
    DotNetCore.Natasha.CSharp.Extension.Codecov
    扩展包。
  2. 编码并获取结果。
builder.WithCodecov();
Assembly asm = builder.GetAssembly();
List<(string MethodName, bool[] Usage)>? list = asm.GetCodecovCollection();

情景假设: A 类中有方法 Method , Method 方法体共 6 行代码逻辑,在执行过程中仅执行了前4行。
result 集合如下:

"MyNamespace.A.Method":
 [0] = true,
 [1] = true,
 [2] = true,
 [3] = true,
 [4] = false,
 [5] = false,

二. 动态只读字典

目前该库还是维护状态,因为它是仅 Natasha 关键项目之外的最重要项目,但目前没有随着 Natasha 发布新版。

动态只读字典通过正向特征树算法,计算最小查找次数(权值)来动态构建一段查找代码,并交由 Natasha 编译,并提供 GetValue 、 TryGetValue 、Change 、索引操作。
我对 .NET8.0 推出的冻结字典进行了性能对比,对比环境 .NET8.0, 字典类型 FrozenDictionary<string, string>, 对比结果:

冻结字典除非后续在 JIT 动态优化出更简洁高效的代码,否则它无法在这个场景中超越动态字典,主打性能的类库越精细越不好优化,特征算法目前来讲十分复杂且构建低效,在特征过多时构建延迟十分明显,代码上需要进行优化与重构,Swifter.Json 作者提出了差异算法,且经过案例推演也证实差异算法在某些场景中可以取得更小的权值,因此我们需要引入差异算法来与特征算法形成竞争,对于代码脚本来说,下一步我将使用更高效的 Runtime API 来提高代码执行性能,争取在下一个版本呢取得更好的性能,后续我们还将横向对比 Indexof / SearchValue 等高性能查找算法,以确定在特殊情况下是否能够借鉴 Runtime 中的算法来提升性能。

在性能过剩的今天,ConcurrentDictionay 已经满足大部分人的需求了,这个类库没有带给我任何金钱收益和荣誉成就,甚至至今为止也未受到过任何需求,因此此库优先级对我来说很低,对一个初级算法都不到的人来说,这库挺令我头疼,也许最好的走向是让一个英语好的,头脑思路清晰的小伙子把算法思路提交给官方,让官方动态优化冻结字典。

结尾

即便 Roslyn 版的 Natasha 已经发布几年时间,但我对 Roslyn 仍然有一种陌生且无力的感觉,Roslyn 文档少的可怜,更多的功能还需要自己去研究挖掘,我会将一些提上日程的重要开发计划发布到 issue 中并征集意见,例如:
https://github.com/dotnetcore/Natasha/issues/240
, 开发不易,求个 Star。

Natasha 项目地址:
https://github.com/dotnetcore/Natasha
Natasha 文档地址:
https://natasha.dotnetcore.xyz/zh-Hans/docs/
(文档站点技术更新,稍晚俞佬将进行修复和上传)

在Mapper层使@Select注解进行SQL语句查询时,往往需要进行参数传入和拼接,一般情况下使用两种占位符#{参数名}和${参数名},两者的区别为:

一、两种占位符的区别

1、参数传入方式的区别

#{}是预编译处理,后台输出的日志会将SQL语句中的#{}占位符输出为?,将传入的Parameter传入SQL语句。

${}是字符串硬替换,会直接将传入的参数直接替换${}占位符,不进行预处理。有SQL注入的风险。

2、参数传入后处理的区别

#{}传入参数后,会自动给参数加上' '(引号)
,例如:

@Select("select name from user where id = #{id}")
String queryNameById(String id);

在传入id为1001之后,输出的sql为:

select name from user where id ='1001'

${}传入的参数会硬替换字符串,不会有其他处理,
例如:

@Select("select name from user where id = ${id}")
String queryNameById(String id);

在传入id为1001后输出的sql是:

select name from user where id = 1001

参数会直接替换${}而不进行其他处理,如果这里你需要给参数加上' ',则需要这么修改代码:

@Select("select name from user where id = '${id}'")
String queryNameById(String id);

这样进行替换后的sql就会变为参数加引号的sql语句。

二、常见报错处理

1、索引超出范围

详细报错为:
Servlet.service()
for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.type.TypeException: Could not set parameters for mapping: ParameterMapping{property='name', mode=IN, javaType=class java.lang.String, jdbcType=null, numericScale=null, resultMapId='null', jdbcTypeName='null', expression='null'}. Cause: org.apache.ibatis.type.TypeException: Error setting non null for parameter #1 with JdbcType null . Try setting a different JdbcType for this parameter or a different configuration property. Cause: com.microsoft.sqlserver.jdbc.SQLServerException: 索引 1 超出范围。] with root cause

日志信息报错索引超出范围,可能是因为语句拼接后出现语法错误,往往造成该错误的是对语句中占位符处的引号处理问题。例如:

@Select("select  kunnr,name1 from openquery(hana2,'select top 10 * from SAPHANADB.kna1 where name1 like ''%${name}%'' and name1 not like ''冻结''')")
List
<Biz> queryBizListByName(String name);

如果这里使用#{}进行占位符,那么组成的sql会变成 like ''%‘name’%'' and,参数会多一个' '进行包裹,语法就会出错。所以不管是使用concat进行拼接,还是直接进行替换,使用两种占位符时都要根据其使用特点,注意包裹的' ',来达到符合自己SQL语法的使用。在出现“索引超出范围”的报错时,可以通过检查自己sql的语法是否出错,来看看是否可以解决问题。

2、“@P0”附近有语法错误

详细报错为:
Servlet.service()
forservlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.apache.ibatis.exceptions.PersistenceException:
### Error querying database. Cause: com.microsoft.sqlserver.jdbc.SQLServerException: “@P0”附近有语法错误。

日志报这个错误,可能是由于你的sql语句中,使用了不支持#{}占位符的函数,例如Top和Order By等函数,是不支持使用#{}占位符的,可以将#{}改为${},使用字符串替换可以解决问题。但要注意#{}改为${}时引号包裹引起的语法问题。

提醒:代码中尽量使用#{}占位符,尽量避免使用${}占位符,因为#{}会更加安全。

AI是当今和未来非常重要的技术领域之一,它在各个行业都有广泛的应用,如医疗保健、金融、教育、制造业等。学习AI可以让你了解和掌握未来技术发展的核心,并为未来的职业发展做好准备。现在有很多开源的Model可以让我们使用,使用这些开源Model在低成本下,我们也能完成自己的任务。

现在我的需求是给公众号设计几张HeadImage,然而我并不擅长设计图形。这时候就是AI发挥作用的时候了。我使用
Stable Diffusion Web UI
结合开源模型,给.NET设计几张壁纸。

Stable Diffusion Web UI

Stable Diffusion是2022年发布的深度学习
文本到图像
生成模型。它主要用于根据文本的描述产生详细图像,而stable-diffusion-webui是一个开源项目,可以通过界面交互的方式来使用 stable-diffusion,极大的降低了使用门槛。我们可以借助Stable Diffusion Web UI与很多开源模型,实现文生图,图生图等功能。

开源模型

开源模型有很多地方可以获取,包括github,huggingface,civitai等。我们去civitai中下载一个流行的revAnimate模型,结合一些糖果风格,金属风格,简约风格的Lora,并结合ControlNet等插件,即可根据一个白底黑字的底图,生成出不同风格的HeadImage。

参数

模型:revAnimate

Lora:candy,Metal,Flag等

扩展:ControlNet,高清修复等

生成效果









转载至我的
博客
,公众号:架构成长指南

在金融系统中,我们会跟钱打交道,而保证在高并发下场景下,对账户余额操作的一致性,是非常重要的,如果代码写的时候没考虑并发一致性,就会导致资损,本人在金融行业干了 8 年多,对这块稍微有点经验,所以这篇聊一下,如何在并发场景下,保证账户余额的一致性

1. 扣款流程是什么样的?

public  void payout(long uid,var payAmount){
   # 查询账户总额
   var  amount= "SELECT amount FROM account WHERE uid=$uid";
   # 计算账户余额
   var balanceAmount = amount-payAmount;
   if(balanceAmount<0) throw 异常
   #更新余额  
   update account set amount=balanceAmount where uid=$uid;   
}

以上流程如果并发量非常低的情况下是没问题的,但是如果在高并发下是很容易出现问题。

2. 在高并发下会出现什么问题?

  1. 订单a和订单 b同一时间都查询到了,账户余额为1000

  1. 订单a扣款200,订单b扣款 100,都满足1000-减去扣款金额大于0

  2. 执行扣款,订单 a修改账户余额为800,订单 b 修改为账户余额为900

此时就出现问题了,如果订单 a 先执行更新,订单 b后执行,那么账户余额最终为900,反之为 800,都不正确,正确余额应该是700,那怎么处理呢?

3. 并发扣款怎么处理?

a. 使用悲观锁

在执行扣款时使用redis、zk或者数据库的
for update
对账户数据进行行级锁,使执行并发操作串型化操作,这里推荐使用
for update
操作,因为引用redis、zk还要考虑他们的异常情况,数据库最简单,也是目前的常规做法,本人曾经参与几大银行项目也是这种方式。

  1. 查询余额,在查询语句上加上
    for update
    ,但是一定要注意where 条件是唯一索引,否则会导致多行数据被锁,同时必须要开始事务,否则
    for update
    没效果,使用分布式数据库中间件还要注意,
    for update
    可能会路由到读节点上。


    伪代码:

    public  void payout(long uid,var payAmount){
       try{
        begin 事务
          # 查询账户总额
          var amount= "SELECT amount FROM account WHERE uid=$uid for update";
          # 计算账户余额
          var balanceAmount = amount - payAmount;
          if(balanceAmount<0) throw 异常
          #更新余额  
          update account set amount=balanceAmount where uid=$uid;   
        }catch(Exception e){
         rollback 事务;
          抛出异常; 
       }  
      commit 事务     
    }
    

b. 使用乐观锁(CAS)

乐观锁的方式也就是是CAS的方式,适合并发量不高情况,如果并发量高大概率都失败在重试,开销也不比悲观锁小,

注意这也是面试题:CAS 适合在使用场景下使用?

1. 增加版本号方式
  1. 在账户表增加乐观锁版本号
account(uid,amout,version)
  1. 查询余额时,同时查询版本号。

    SELECT amount,version FROM account WHERE uid=$uid
    
  2. 每次更新余额时,必须版本号相等,并且版本号每次要修改。

    update account set amount=余额,version=newVersion where uid=$uid and version=$oldVersion
    
2. 使用原有金额值比对更新

在执行账户余额更新时,where 条件中增加第一次查出来的账户余额,即初始余额,如果在执行更新时,初始余额没变则更新成功,否则肯定是更新了,同时数据库也会返回受影响的行数,来判断是否更新成功,如果没成功就再次重试。

update account set amount=余额  where uid=$uid and amount=$oldAmount

以下是伪代码,遇到失败回滚事务并抛出异常,上层调用方法要考虑捕获异常在进行重试

public void payout(long uid,var payAmount){
     try{
      
      begin 事务
        # 查询账户总额
        var amount= "SELECT amount FROM account WHERE uid=$uid for update";
        # 计算账户余额
        var balanceAmount = amount- payAmount;
        if(balanceAmount<0) throw 异常
        #更新余额  
        int count=update account set amount=$balanceAmount where uid=$uid and amount=$amount;   
        ###注意如果更新成功返回count为1
         if(count<1){
           抛出异常重试;
         }
      }catch(Exception e){
        rollback 事务;
           抛出异常; 
    }
   commit 事务     
  
}

具体到以上示例

订单a 执行

update account set amount=800 where uid=$uid and amount=1000;

订单b 执行

update account set amount=900 where uid=$uid and amount=1000;

以上两笔执行只有一笔能成功,因为amount 变了。

4. 使用乐观锁会不会存在aba 的问题

什么是 aba?

线程 1:获取出数据的初始值是a,如果数据仍是a的时候,修改才能成功

线程 2:将数据修改成b

线程 3:将数据修改成 a

线程 1:执行cas,发现数据还是 a,进行数据修改

上述场景,线程1在修改数据时,虽然还是a,但已经不是初始条件的a了,中间发生了a变b,b又变a,此 a 非彼 a,但是成功修改了,在有些场景下会有问题,这就是 aba

但是以上场景,对账户扣款不会出现问题,因为余额 1000 就是 1000,是相同的,举个例子,

订单a:获取出账户余额为 1000,期望余额是 1000的时候,才能修改成功。

订单b:取了 100,将余额修改成了900。

订单c:存进去了100,将余额修改成了 1000。

订单 a:检查账户余额为1000,进行扣款200,账户余额变成了800。

以上场景账户资金损失吗没有吧,不过为了避免产生误解,推荐还是使用版本号的方式!

5. 总结

以上我们讲了在高并发场景在如何保证结果一致性方式,在并发量高情况下推荐使用悲观锁的方式,如果并发量不高可以考虑使用乐观锁,推荐使用版本号方式,同时要注意幂等性与aba的问题。

扫描下面的二维码关注我们的微信公众帐号,在微信公众帐号中回复◉加群◉即可加入到我们的技术讨论群里面共同学习。

使用STM32CubeMX软件配置STM32F407开发板RTC实现入侵检测和时间戳功能,具体为周期唤醒回调中使用串口输出当前RTC时间,按键WK_UP存储当前RTC时间到备份寄存器,按键KEY_2从备份寄存器中读取上次存储的时间,按键KEY_1负责产生入侵事件

1、准备材料

开发板(
正点原子stm32f407探索者开发板V2.4

ST-LINK/V2驱动
STM32CubeMX软件(
Version 6.10.0

keil µVision5 IDE(
MDK-Arm

CH340G Windows系统驱动程序(
CH341SER.EXE

XCOM V2.6串口助手
杜邦线一根

2、实验目标

使用STM32CubeMX软件配置STM32F407开发板
RTC实现入侵检测和时间戳功能
,具体为周期唤醒回调中使用串口输出当前RTC时间,按键WK_UP存储当前RTC时间到备份寄存器,按键KEY_2从备份寄存器中读取上次存储的时间,按键KEY_1负责产生入侵事件

3、实验流程

3.0、前提知识

STM32F407的RTC上有两个入侵检测模块
,但是笔者使用的LQFP144封装的STM32F407ZGT6只有一个入侵检测模块,只有一个入侵检测模块的STM32F407单片机是
利用RTC_AF1(PC13)引脚来进行触发的
,和按键外部中断类似,如果设置入侵检测触发为低电平触发,那么当PC13为低电平时就会进入Tampere1事件回调函数,
当发生入侵事件时,RTC的20个备份寄存器中的值会全部丢失

由于开发板上PC13引脚并没有按键控制,不方便实现其电平的翻转变化操作,因此
本实验需要一根杜邦线,将按键KEY_1所使用的PE3引脚与PC13引脚短接
,相当于使用按键KEY_1来间接控制PC13的电平变化,如下图所示,当按键KEY_1松开时,此时PE3/PC13状态应该由外部上/下拉决定,而当按键KEY_1按下时,PE3/PC13的状态应该为低电平,通过设置PC13外部上拉,就可以实现KEY_1按键松开时为高电平,按下为低电平

3.1、CubeMX相关配置

请阅读“
STM32CubeMX教程10 RTC 实时时钟 - 周期唤醒、闹钟A/B事件和备份寄存器
”实验3.1.1小节配置RCC和SYS

3.1.1、时钟树配置

系统时钟树配置与上一实验一致,均设置为STM32F407总线能达到的最高时钟频率,配置LSE,RTC时钟频率为32.768kHz,具体如下图所示

3.1.2、外设参数配置

本实验需要需要初始化USART1作为输出信息渠道,具体配置步骤请阅读“
STM32CubeMX教程9 USART/UART 异步通信

单击Pinout & Configuration页面左边Timers/RTC,并在页面中间激活日历,周期唤醒WakeUp采用内部模式,勾选入侵检测1将其输入复用到引脚RTC_AF1(PC13),则此后PC13引脚便作为入侵检测引脚,具体配置如下图所示

与上一小节实验类似,需要配置RTC通用参数、日历日期时间、周期唤醒参数和入侵检测参数

① 滤波设置中,如果不滤波则入侵检测的触发方式只能选择边沿触发,而如果选择滤波,则触发方式只能选择电平触发,这里由于使用的机械按键存在抖动,因此对输入滤波

② 入侵引脚是否上拉设置中,如上述3.0小节所述,我们需要PE3/PC13外部上拉才能实现目标,因此此处选择上拉

③ 保存了入侵时间戳就可以在Tampere1事件回调函数中使用HAL_RTCEx_GetTimeStamp获取入侵时间戳

④ 入侵检测触发方式设置中,由于按键按下为低电平,因此这里选择低电平

3.1.3、外设中断配置

在Pinout & Configuration页面左边System Core/NVIC中勾选入侵检测及周期唤醒中断,然后选择合适的中断优先级即可

3.2、生成代码

请先阅读“
STM32CubeMX教程1 工程建立
”实验3.4.3小节配置Project Manager

单击页面右上角GENERATE CODE生成工程

3.2.1、外设初始化调用流程

与上一小节RTC初始化函数MX_RTC_Init对比,可以发现本小节的初始化函数中减少了闹钟A/B的初始化,但是新增加了入侵检测的初始化,如下图所示,也即我们在CubeMX中设置的参数,类似的中断相关的初始化设置仍然在HAL_RTC_MspInit函数中

3.2.2、外设中断调用流程

在CubeMX中勾选RTC入侵检测启动中断后,在stm32f4xx_it.c中均会生成对应的中断服务函数TAMP_STAMP_IRQHandler()

在该TAMP_STAMP_IRQHandler()中断服务函数中调用了HAL库HAL_RTCEx_TamperTimeStampIRQHandler()函数统一处理时间戳/入侵事件

最终根据发生的事件来源
调用了时间戳事件回调函数HAL_RTCEx_TimeStampEventCallback()、入侵检测1事件回调函数HAL_RTCEx_Tamper1EventCallback()和入侵检测2事件HAL_RTCEx_Tamper2EventCallback()

具体流程如下图所示

3.2.3、添加其他必要代码

由于无入侵检测2,笔者这里只实现了入侵检测1事件回调函数HAL_RTCEx_Tamper1EventCallback(RTC_HandleTypeDef *hrtc),将其实现在了rtc.c中,另外周期唤醒回调函数内容与上一小结内容一致,这里不再赘述,入侵检测1事件回调函数具体代码如下图所示

源代码如下

/*Tampere1事件回调函数*/
void HAL_RTCEx_Tamper1EventCallback(RTC_HandleTypeDef *hrtc)
{
    RTC_TimeTypeDef sTime;
    RTC_DateTypeDef sDate;
    if(HAL_RTCEx_GetTimeStamp(hrtc, &sTime, &sDate, RTC_FORMAT_BIN) == HAL_OK)
    {
        char str[24];
        sprintf(str,"TimeStamp = %2d:%2d:%2d\r\n",sTime.Hours,sTime.Minutes,sTime.Seconds);
        printf("Tampere1 Event Happend, %s", str);
    }
    HAL_GPIO_TogglePin(GREEN_LED_GPIO_Port,GREEN_LED_Pin);
}

经过了上述的过程之后目前还缺少两个操作,利用按键WK_UP存储当前RTC时间到备份寄存器,按键KEY_2从备份寄存器中读取上次存储的时间,其代码实现在了主函数主循环中,简单采用轮询的方式处理按键,如下图所示

源代码如下


/*按下WK_UP按键将当前时间存储到备份寄存器*/
if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
{
    HAL_Delay(50);
    if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
    {
        RTC_TimeTypeDef sTime;
        RTC_DateTypeDef sDate;
        if(HAL_RTC_GetTime(&hrtc, &sTime,  RTC_FORMAT_BIN) == HAL_OK)
        {
            HAL_RTC_GetDate(&hrtc, &sDate,  RTC_FORMAT_BIN);
            HAL_RTCEx_BKUPWrite(&hrtc,RTC_BKP_DR2, sTime.Hours);
            HAL_RTCEx_BKUPWrite(&hrtc,RTC_BKP_DR3, sTime.Minutes);
            HAL_RTCEx_BKUPWrite(&hrtc,RTC_BKP_DR4, sTime.Seconds);
            char timeStr[30];
            sprintf(timeStr,"%2d:%2d:%2d",sTime.Hours,sTime.Minutes,sTime.Seconds);
            printf("Store %s to the backup register\r\n", timeStr);
            while(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin));
        }
    }
}

/*按下KEY2按键将存储到备份寄存器的时间利用串口输出*/
if(HAL_GPIO_ReadPin(KEY_2_GPIO_Port,KEY_2_Pin) == GPIO_PIN_RESET)
{
    HAL_Delay(50);
    if(HAL_GPIO_ReadPin(KEY_2_GPIO_Port,KEY_2_Pin) == GPIO_PIN_RESET)
    {
        uint32_t  sHour,sMinute,sSecond;
        sHour = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR2);	//Hour
        sMinute = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR3);	//Minute
        sSecond = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR4);	//Second
        char timeStr[30];
        sprintf(timeStr,"%u:%u:%u",sHour,sMinute,sSecond);
        printf("Read out %s from the backup register\r\n", timeStr);
        while(!HAL_GPIO_ReadPin(KEY_2_GPIO_Port,KEY_2_Pin));
    }
}

4、常用函数

/*时间戳回调函数*/
void HAL_RTCEx_TimeStampEventCallback(RTC_HandleTypeDef *hrtc)
 
/*Tampere1事件回调函数*/
void HAL_RTCEx_Tamper1EventCallback(RTC_HandleTypeDef *hrtc)
 
/*Tampere2事件回调函数*/
void HAL_RTCEx_Tamper2EventCallback(RTC_HandleTypeDef *hrtc)
 
/*获取RTC时间戳*/
HAL_StatusTypeDef HAL_RTCEx_GetTimeStamp(RTC_HandleTypeDef *hrtc, RTC_TimeTypeDef *sTimeStamp, RTC_DateTypeDef *sTimeStampDate, uint32_t Format)

5、烧录验证

5.1、具体步骤

“RTC Mode and Configuration中启用内部模式的WakeUp周期唤醒 -> 勾选入侵检测Tamper1 Routed to AF1 -> 配置合适的日历通用参数、日历日期时间、周期唤醒参数和入侵检测参数 -> NVIC中勾选RTC周期唤醒中断及RTC入侵检测中断,并选择合适的中断优先级 -> 在生成的工程代码中重新实现周期唤醒回调函数、Tampere1事件回调函数HAL_RTCEx_Tamper1EventCallback -> 添加必要的代码逻辑(具体看上述3.2)”

5.2、实验现象

烧录程序,利用杜邦线短接PE3和PC13,当开发板上电后,会在周期唤醒回调函数中不断地输出当前RTC的时间,另外开发板上的红色LED灯也会不断地闪烁,当按下开发板上的WK_UP按键之后会将当前RTC日历的时间存储到备份寄存器RTC_BKP_DR2~4中,按下开发板上的KEY_2按键可以从备份寄存器中将上次存储的时间读出来

然后当按下按键KEY_1的时候,会发生入侵事件,此时入侵被检测到,会触发Tampere1事件回调函数通过串口输出入侵事件的信息,并且如果再去通过KEY_2按键读取备份寄存器中存储的时间会发现由于入侵的发生,备份寄存器中的值已经被清空

上述整个流程串口输出信息如下图所示

6、奇怪的现象

有时候会出现写备份寄存器写不进去的情况,如果你也遇到了,可以尝试将开发板完全断电(电源线、USB串口和调试器接口),然后重新上电复位再向备份寄存器中写入试试

参考资料

STM32Cube高效开发教程(基础篇)

更多内容请浏览
OSnotes的CSDN博客