由于两个月的奋战,导致很久没更新了。就是上回老周说的那个产线和机械手搬货的项目,好不容易等到工厂放假了,我就偷偷乐了。当然也过年了,老周先给大伙伴们拜年了,P话不多讲,就祝大家身体健康、生活愉快。其实生活和健康是密不可分的,想活得好,就得健康。包括身体健康、思想健康、心理健康、精神健康。不能以为我无病无痛就很健康,你起码要全方位健康。
不管你的工作是什么,忙或者不忙,报酬高或低,但是,人,总得活,总得过日子。咱们最好多给自己点福利,多整点可以自娱自乐的东西,这就是生活。下棋、打游戏、绘画、书法、钓鱼、飙车、唢呐……不管玩点啥,只要积极正向的就好,可以大大降低得抑郁症、高血压的机率;可以减少70%无意义的烦恼;可以降低跳楼风险;在这个礼崩乐坏的社会环境中,可以抵御精神污染……总之,益处是大大的有。
然后老周再说一件事,一月份的时候常去工厂调试,也认识了机械臂厂商派的技术支持——吴大工程师。由于工厂所处地段非常繁华,因此每次出差,午饭只能在附近一家四川小吃店解决。毕竟这方圆百十里也仅此一家。不去那里吃饭除非自带面包蹲马路边啃,工厂不供食也不供午休场所。刚开始几次出差还真的像个傻子似的蹲马路边午休。后来去多了,直接钻进工厂的会议室睡午觉。
有一天吃午饭时,吴老师说:你说什么样的人编程水平最高?
我直接从潜意识深处回答他:我做一个排序,仅供参考。编程水平从高到低排行:
1、黑客。虽然大家都说黑客一代不如一代,但目前来说,这群人还是最强的;
2、纯粹技术爱好者;
3、著名开源项目贡献者。毕竟拿不出手的代码也不好意思与人分享;
4、做过许多项目的一线开发者。我强调的项目数量多,而不是长年只维护一个项目的。只有数量多你学到的才多;
5、社区贡献较多者,这个和3差不多。不过,老周认为的社区贡献就是不仅提供代码,还提供文档、思路、技巧等;
6、刚入坑但基础较好的开发者;
7、培训机构的吹牛专业户;
8、大学老师/教授;
9、短视频平台上的砖家、成宫人士;
10、刚学会写 main 函数的小朋友。
==========================================================================================================
下面进入主题,咱们今天聊聊 IChangeToken。它的主要功能是提供更改通知。比如你的配置源发生改变了,要通知配置的使用者重新加载。你可能会疑惑,这货跟使用事件有啥区别?这个老周也不好下结论,应该是为异步代码准备的吧。
下面是IChangeToken 接口的成员:
bool HasChanged { get; } bool ActiveChangeCallbacks { get; } IDisposable RegisterChangeCallback(Actionobject?> callback, object? state);
这个 Change Token 思路很清奇,实际功能类似事件,就是更改通知。咱们可以了解一下其原理,但如果你觉得太绕,不想了解也没关系的。在自定义配置源时,咱们是不需要自己写 Change Token 的,框架已有现成的。我们只要知道要触发更改通知时调用相关成员就行。
如果你想看源码的话,老周可以告你哪些文件(github 项目是 dotnetruntime):
1、runtime-mainsrclibrariesCommonsrcExtensionsChangeCallbackRegistrar.cs:这个主要是UnsafeRegisterChangeCallback 方法,用于注册回调委托;
2、runtime-mainsrclibrariesMicrosoft.Extensions.PrimitivessrcChangeToken.cs:这个类主要是提供静态的辅助方法,用于注册回调委托。它的好处是可以循环——注册回调后,触发后委托被调用;调用完又自动重新注册,使得 Change Token 可以多次触发;
3、runtime-mainsrclibrariesMicrosoft.Extensions.PrimitivessrcCancellationChangeToken.cs:这个类是真正实现IChangeToken 接口的;
4、runtime-mainsrclibrariesMicrosoft.Extensions.ConfigurationsrcConfigurationReloadToken.cs:这个也是实现IChangeToken 接口,而且它才是咱们今天的主角,该类就是为重新加载配置数据而提供的。调用它的 OnReload 方法可以触发更改通知。
看了上面这些,你可能更疑惑了。啥原理?为啥 Token 只能触发一次?为何要重新注册回调?
咱们用一个简单例子演练一下。
static void Main(string[] args) { CancellationTokenSource cs = new(); // 这里获取token CancellationToken token = cs.Token; // token 可以注册回调 token.Register(() => { Console.WriteLine("你按下了【K】键"); }); // 启动一个新task Task myTask = Task.Run(() => { // 等待输入,如果按下【K】键,就让CancellationTokenSource取消 ConsoleKeyInfo keyInfo; while(true) { keyInfo = Console.ReadKey(true); if(keyInfo.Key == ConsoleKey.K) { // 取消 cs.Cancel(); break; } } }); // 主线程等待任务完成 Task.WaitAll(myTask); }
CancellationTokenSource 类表示一个取消任务的标记,访问它的 Token 属性可以获得一个CancellationToken 结构体实例,可以检索它的IsCancellationRequested 属性以明确是否有取消请求(有则true,无则false)。
还有更重要的,CancellationToken 结构体的 Register 方法可以注册一个委托作为回调,当收到取消请求后会触发这个委托。对的,这个就是 Change Token 灵魂所在了。一旦回调被触发后,CancellationTokenSource 就处于取消状态了,你无法再次触发,除非重置或重新实例化。这就是回调只能触发一次的原因。
下面,咱们完成一个简单的演示——用数据库做配置源。在 SQL Server 里面随便建个数据库,然后添加一个表,名为tb_configdata。它有四个字段:
CREATE TABLE [dbo].[tb_configdata]( [ID] [int] NOT NULL, [config_key] [nvarchar](15) NOT NULL, [config_value] [nvarchar](30) NOT NULL, [remark] [nvarchar](50) NULL, CONSTRAINT [PK_tb_configdata] PRIMARY KEY CLUSTERED ( [ID] ASC, [config_key] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] ) ON [PRIMARY] GO
ID和config_key设为主键,config_value 是配置的值,remark 是备注。备注字段其实可以不用,但实际应用的时候,可以用来给配置项写点注释。
然后,在程序里面咱们用到 EF Core,故要先生成与表对应的实体类。这里老周就不用工具了,直接手写更有效率。
// 实体类 public class MyConfigData { public int ID { get; set; } public string ConfigKey { get; set; } = string.Empty; public string ConfigValue { get; set; } = string.Empty; public string? Remark { get; set; } } // 数据库上下文对象 public class DemoConfigDBContext : DbContext { public DbSet ConfigData => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer("Data Source=DEV-PCSQLTEST;Initial Catalog=Demo;Integrated Security=True;Connect Timeout=30;Encrypt=True;Trust Server Certificate=True;Application Intent=ReadWrite;Multi Subnet Failover=False"); } protected override void OnModelCreating(ModelBuilder modelbd) { modelbd.Entity() .ToTable("tb_configdata") .HasKey(c => new { c.ID, c.ConfigKey }); modelbd.Entity() .Property(c => c.ConfigKey) .HasColumnName("config_key"); modelbd.Entity() .Property(c => c.ConfigValue) .HasColumnName("config_value"); modelbd.Entity() .Property(c => c.Remark) .HasColumnName("remark"); } }
上述代码的情况特殊,实体类的名称和成员名称与数据表并不一致,所以在重写OnModelCreating 方法时,需要进行映射。
1、ToTable(“tb_configdata”) 告诉 EF 实体类对应的数据表是 tb_configdata;
2、HasKey(c => new { c.ID, c.ConfigKey }):表明该实体有两个主键——ID和ConfigKey。这里指定的是实体类的属性,而不是数据表的字段名,因为后面咱们会进行列映射;
3、HasColumnName(“config_key”):告诉 EF,实体的 ConfigKey 属性对应的是数据表中 config_key。后面的几个属性的道理一样,都是列映射。
做映射就类似于填坑,如果你不想挖坑,那就直接让实体类名与表名一样,属性名与表字段(列)一样,这样就省事多了。不过,在实际使用中真没有那么美好。很多时候数据库是小李负责的,人家早就建好了,存储过程都写了几万个了。后面前台程序是老张来开发,对老张来说,要么把实体的命名与数据库的一致,要么就做一下映射。多数情况下是要映射的,毕竟很多时候数据库对象的命名都比较奇葩。尤其有上千个表的时候,为了看得顺眼,很多人喜欢这样给数据表命名:ta_XXX、ta_YYY、tb_ZZZ、tc_FFF、tx_PPP、ty_EEE、tz_WWW。还有这样命名的:m1_Report、m2_ReportDetails…… m105_TMD、m106_WNM、m107_DOUBI。
这种命名用在实体类上面确实很不优雅,所以映射就很必要了。
此处咱们不用直接实现IConfigurationProvider 接口,而是从ConfigurationProvider 类派生就行了。自定义配置源的东东老周以前写过,只是当时没有实现更改通知。
public class MyConfigurationProvider : ConfigurationProvider, IDisposable { private System.Threading.Timer theTimer; public MyConfigurationProvider() { theTimer = new Timer(OnTimer, null, 100, 10000); } private void OnTimer(object? state) { // 先调用Load方法,然后用OnReload触发更新通知 Load(); OnReload(); } public void Dispose() { theTimer?.Change(0, 0); theTimer?.Dispose(); } public override void Load() { // 先读取一下 using DemoConfigDBContext dbctx = new(); // 如果无数据,先初始化 if(dbctx.ConfigData.Count() == 0) { InitData(dbctx.ConfigData); } // 加载数据 Data = dbctx.ConfigData.ToDictionary(k => k.ConfigKey, k => (string?)k.ConfigValue); // 本地函数 void InitData(DbSet set) { int _id = 1; set.Add(new() { ID = _id, ConfigKey = "page_size", ConfigValue = "25" }); _id += 1; set.Add(new() { ID = _id, ConfigKey = "format", ConfigValue = "xml" }); _id += 1; set.Add(new() { ID = _id, ConfigKey = "limited_height", ConfigValue = "1450" }); _id += 1; set.Add(new() { ID = _id, ConfigKey = "msg_lead", ConfigValue = "TDXA_" }); // 保存数据 dbctx.SaveChanges(); } } }
由于老周不知道怎么监控数据库更新,最简单的办法就是用定时器循环检查。重点是重写 Load 方法,完成加载配置的逻辑。Load 方法覆写后不需要调用 base 的 Load 方法,因为基类的方法是空的,调用了也没毛用。
在 Timer 对象调用的方法(OnTimer)中,先调用 Load 方法,再调用 OnReload 方法。这样就可以在加载数据后触发更改通知。
然后实现IConfigurationSource 接口,提供MyConfigurationProvider 实例。
public class MyConfigurationSource : IConfigurationSource { public IConfigurationProvider Build(IConfigurationBuilder builder) { return new MyConfigurationProvider(); } }
默认的配置源有JSON文件、命令行、环境变量等,为了排除干扰,便于查看效果,在 Main 方法中咱们先把配置源列表清空,再添加咱们自定义的配置源。
var builder = WebApplication.CreateBuilder(args); // 清空配置源 builder.Configuration.Sources.Clear(); // 添加配置源到Sources builder.Configuration.Sources.Add(new MyConfigurationSource()); var app = builder.Build();
最后,可以做个简单测试,直接注入 Mini-API 中读取配置。
app.MapGet("/", (IConfiguration config) => { StringBuilder bd = new(); foreach(var kp in config.AsEnumerable()) { bd.AppendLine($"{kp.Key} = {kp.Value}"); } return bd.ToString(); });
运行效果如下:
这时候咱们到数据库里把配置值改一下。
update tb_configdata set config_value = N'55' where config_key = N'page_size' update tb_configdata set config_value = N'1900' where config_key = N'limited_height'
接着回应用程序的页面,刷新一下,配置值已更新。
这里你可能会有个疑问:连接字符串硬编码了不太好,要不写在配置文件中,可是,写在JSON文件中咱们怎么获取呢?毕竟ConfigurationProvider 不使用依赖注入。
IConfigurationSource 不是有个 Build 方法吗?Build 方法不是有个参数是IConfigurationBuilder 吗?用它,用它,狠狠地用它。
public class MyConfigurationSource : IConfigurationSource { public IConfigurationProvider Build(IConfigurationBuilder builder) { // 此处可以临时build一个配置树,就能获取到JSON配置文件里面的连接字符串了 var config = builder.Build(); string connStr = config["ConnectionStrings:test"]!; return new MyConfigurationProvider(connStr); } }
前面定义的一些类也要改一下。
先是MyConfigurationProvider 的构造函数。
public class MyConfigurationProvider : ConfigurationProvider, IDisposable { private System.Threading.Timer theTimer; private string connectString; public MyConfigurationProvider(string cnnstr) { connectString = cnnstr; …… } …… }
DemoConfigDBContext 类是连接字符串的最终使用者,所以也要改一下。
public class DemoConfigDBContext : DbContext { private string connStr; public DemoConfigDBContext(string connectionString) { connStr = connectionString; } …… protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(connStr); } }
在appsettings.json 文件中配置连接字符串。
{ "Logging": { …… }, "AllowedHosts": "*", "ConnectionStrings": { "test": "Data Source=DEV-PCSQLTEST;Initial Catalog=Demo;Integrated Security=True;Connect Timeout=30;Encrypt=True;Trust Server Certificate=True;Application Intent=ReadWrite;Multi Subnet Failover=False" } }
回到 Main 方法,咱们还得加上 JSON 配置源。
var builder = WebApplication.CreateBuilder(args); // 清空配置源 builder.Configuration.Sources.Clear(); // 添加配置源到Sources builder.Configuration.AddJsonFile("appsettings.json"); builder.Configuration.Sources.Add(new MyConfigurationSource()); var app = builder.Build();
其他的不变。
—————————————————————————————————–
接下来,咱们弄个一对多的例子。逻辑是这样的:启动程序显示主窗口,接着创建五个子窗口。主窗口上有个大大的按钮,点击后,五个子窗口会收到通知。大概就这个样子:
子窗口名为 TextForm,代码如下:
internal class TestForm : Form { private IDisposable _changeTokenReg; private TextBox _txtMsg; public TestForm(Func getToken) { // 初始化子级控件 _txtMsg = new() { Dock = DockStyle.Fill, Margin = new Padding(5), Multiline = true, ScrollBars = ScrollBars.Vertical }; Controls.Add(_txtMsg); _changeTokenReg = ChangeToken.OnChange(getToken, OnCallback); } // 回调方法 void OnCallback() { DateTime curtime = DateTime.Now; string str = $"{curtime.ToLongTimeString()} 新年快乐rn"; _txtMsg.BeginInvoke(() => { _txtMsg.AppendText(str); }); } protected override void Dispose(bool disposing) { // 释放对象 if (disposing) { _changeTokenReg?.Dispose(); } base.Dispose(disposing); } }
窗口上只放了一个文本框。上面代码中,使用了ChangeToken.OnChange 静态方法,为 Change Token 注册回调委托,本例中回调委托绑定的是 OnCallback 方法,也就是说:当 Change Token 触发后服务器托管网会在文本框中追加文本。OnChange 静态方法有两个重载:
// 咱们示例中用的是这个版本 static IDisposable OnChange(Func changeTokenProducer, Action changeTokenConsumer); // 这是另一个重载 static IDisposable OnChange(Func changeTokenProducer, Action changeTokenConsumer, TState state);
上述例子用的是第一个,其实里面调用的也是第二个重载,只是把咱们传递的 OnCallback 方法当作 TState 传进去了。
请大伙伴暂时记住changeTokenProducer 和changeTokenConsumer 这两参数。changeTokenProducer 也是一个委托,返回 IChangeToken。用的时候一定要注意,每次触发之前,Change Token 要先创建新实例。注意是先创建新实例再触发,否则会导致无限。尽管内部会判断HasChanged 属性,可问题是这个判断是在注册回调之后的。这个是跟 Change Token 的清奇逻辑有关,咱们看看 OnChage 的源代码就明白了。
public static IDisposable OnChange(Func changeTokenProducer, Action changeTokenConsumer, TState state) { if (changeTokenProducer is null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.changeTokenProducer); } if (changeTokenConsumer is null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.changeTokenConsumer); } return new ChangeTokenRegistration(changeTokenProducer, changeTokenConsumer, state); }
简单来说,就是返回一个ChangeTokenRegistration 实例,这是个私有类,咱们是访问不到的,以 IDisposable 接口公开。其中,它有两个方法是递归调用的:
private void OnChangeTokenFired() { // The order here is important. We need to take the token and then apply our changes BEFORE // registering. This prevents us from possible having two change updates to process concurrently. // // If the token changes after we take the token, then we'll process the update immediately upon // registering the callback. IChangeToken? token = _changeTokenProducer(); try { _changeTokenConsumer(_state); } finally { // We always want to ensure the callback is registered RegisterChangeTokenCallback(token); } } private void RegisterChangeTokenCallback(IChangeToken? token) { if (token is null) { return; } IDisposable registraton = token.RegisterChangeCallback(s => ((ChangeTokenRegistration?)s)!.OnChangeTokenFired(), this); if (token.HasChanged && token.ActiveChangeCallbacks) { registraton?.Dispose(); return; } SetDisposable(registraton); }
在ChangeTokenRegistration 类的构造函数中,先调用RegisterChangeTokenCallback 方法,开始了整个递归套娃的过程。在RegisterChangeTokenCallback 方法中,为 token 注册的回调就是调用OnChangeTokenFired 方法。
而OnChangeTokenFired 方法中,是先获取新的 Change Token,再触发旧 token。最后,又调用RegisterChangeTokenCallback 方法,实现了无限套娃的逻辑。
因此,咱们在用的时候,必须先创建新的 Change Token 实例,然后再调用RegisterChangeTokenCallback 实例的Cancel 方法。不然这无限套娃会一直进行到栈溢出,除非你提前把ChangeTokenRegistration 实例 Dispose 掉(由 OnChange 静态方法返回)。可是那样的话,你就不能多次接收更改了。
下面就是主窗口部分,也是最危险的部分——必须按照咱们上面分析的顺序进行,不然会 Stack Overflow。
public partial class Form1 : Form { private CancellationTokenSource _cancelTkSource; private CancellationChangeToken _changeToken; public Form1() { InitializeComponent(); _cancelTkSource = new CancellationTokenSource(); _changeToken = new(_cancelTkSource.Token); button1.Click += OnButton1Click; button2.Click += OnButton2Click; } private void OnButton2Click(object? sender, EventArgs e) { for(int t= 0; t 5; t++) { TestForm frm = new(GetChangeToken); frm.Text = "窗口" + (t + 1); frm.Size = new Size(300, 240); frm.StartPosition = FormStartPosition.CenterParent; frm.Show(this); } } // 这个地方就是触发token了,所以要先换上新的实例 private void OnButton1Click(object? sender, EventArgs e) { // 先创建新的实例 var oldsource = Interlocked.Exchange(ref _cancelTkSource, new CancellationTokenSource()); Interlocked.Exchange(ref _changeToken, new CancellationChangeToken(_cancelTkSource.Token)); // 只要CancellationTokenSource一取消,其他客户端会收到通知 oldsource.Cancel(); } // 这个方法传递给 TestForm 构造函数,再传给 OnChange 静态方法 public IChangeToken? GetChangeToken() { return _changeToken; } }
按钮1的单击事件处理方法就是触发点,所以,CancellationTokenSource、CancellationChangeToken 要先换成新的实例,然后再用旧的实例去 Cancel。这里用 Interlocked 类会好一些,毕竟要考虑异步的情况,虽然服务器托管网咱这里都是在UI线程上传递的,但还是遵守这个习惯好一些。
这样处理就能避免栈溢出了。运行后,先打开五个子窗口(多点击一次就能创建十个子窗口)。接着点击大大按钮,五个子窗口就能收到通知了。
好了,这次就聊到这儿了。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net
查看当前时间及时区状态 sudo timedatectl status # 显示当前时服务器托管网区为Asia/Shanghai 查看当前系统时间 sudo date 查看当前服务器托管网系统时间及时区 sudo date -R # 显示当前时间及对应时区,时…