wenmo8 发布的文章

导航

  • 前言
  • 火线告警,CPU飚了
  • 服务重启,迅速救火
  • 黑盒:无尽的猜测和不安
  • Arthas:锋利的Java诊断工具
  • 在线追踪Cpu占比高的代码段
  • 代码重构,星夜上线,稳了
  • 结语
  • 参考

肮脏的代码必须重构,但漂亮的代码也需要很多重构。

前言

有些代码在当初编写的时候是非常稳健的,但是随着数据量的不断增加,有些代码的“性能瓶颈”逐渐暴露出来。

这就可能会导致一些不可预知的线上事故。

那么,如何快速定位问题和处置问题就变得极其重要。

火线告警,CPU飚了

运维三板斧,重启、重装、重新买!

在多年的职业历练中,我养成了一个习惯——随时关注群里用户的反馈。

在一个阳光很好的午后,我和同事们正在加班加点的赶一个版本。

突然,群里有人反馈,线上的一个功能出现了问题,需要紧急处理。



随即便是更多的业务对接群开始炸锅。

上个月因为数据库性能问题,已经出现了几次线上宕机的情况,被用户吐槽。
为此,我们做了大量的优化工作:

  • 慢sql优化
  • 去高频接口
  • 数据冷热分离
  • ...

今天再次遇到这样的问题,我们惊讶了几秒,然后很快恢复了镇定。

服务重启,迅速救火

我和业务团队的同事一边安抚用户的情绪,一边查看报警日志。

紧急着查看了报警日志,发现部署该业务接口的两台ecs CPU飙高了...



再看数据库的CPU使用率并未报警。

当机立断,先重启一下服务。(PS:不要慌,不要慌,不要慌!)

大约两分钟之后,我们验证了可用性,并查看ecs和数据库各项指标,正常。

于是大家一一回复了用户群,对接群终于安静了。

黑盒:无尽的猜测和不安

路漫漫其修远兮,吾将上下而求索。

在这个时候,我已经开始了我的思考——是哪个功能或者哪句代码引发了ecs cpu标高呢?

过去,我们的思路总是先去查看网关日志,从时间点上排查可能导致性能问题的接口,然后逐渐深入。

然而,这个项目已经迭代3年多了,接口繁多,想快速定位无疑是大海捞针。

所以,对于这种黑盒般的问题,因为缺乏诊断工具,往往让我们陷入无尽的猜测和不安中。

是否有这样的工具帮助我快速定位到问题的代码呢?

Arthas:锋利的Java诊断工具

在这次的问题诊断中,我使用了Arthas来进行线上问题的诊断。

Arthas
(阿尔萨斯)(是
Alibaba
开源的Java诊断工具,深受开发者喜爱。
在线排查问题,无需重启、动态跟踪Java代码、实时监控 JVM 状态。

Arthas
支持JDK 6+,支持
Linux/Mac/Windows
,采用命令行交互模式,同时提供丰富的
Tab
自动补全功能,进一步方便进行问题的定位和诊断。

当你遇到以下类似问题而束手无策时,Arthas 可以帮助你解决:

  • 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  • 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  • 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  • 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  • 是否有一个全局视角来查看系统的运行状况?
  • 有什么办法可以监控到 JVM 的实时运行状态?
  • 怎么快速定位应用的热点,生成火焰图?

官方教程

使用arthas-boot(推荐)

下载arthas-boot.jar,然后用java -jar的方式启动:

  • 执行该程序的用户需要和目标进程具有相同的权限。比如以admin用户来执行:sudo su admin && java -jar arthas-boot.jar 或 sudo -u admin -EH java -jar arthas-boot.jar。
  • 如果 attach 不上目标进程,可以查看~/logs/arthas/ 目录下的日志。
  • 如果下载速度比较慢,可以使用 aliyun 的镜像:java -jar arthas-boot.jar --repo-mirror aliyun --use-http
  • java -jar arthas-boot.jar -h 打印更多参数信息。
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar





选择应用java进程:

blog-webapp-0.0.1-SNAPSHOT.jar
进程是第1个,则输入1,再输入回车/enter。Arthas 会 attach 到目标进程上,并输出日志:

[INFO] Found existing java process, please choose one and input the serial number of the process, eg : 1. Then hit ENTER.
* [1]: 27575 blog-webapp-0.0.1-SNAPSHOT.jar
1
[INFO] local lastest version: 3.7.2, remote lastest version: 4.0.2, try to download from remote.
[INFO] Start download arthas from remote server: https://arthas.aliyun.com/download/4.0.2?mirror=aliyun
[INFO] Download arthas success.
[INFO] arthas home: /root/.arthas/lib/4.0.2/arthas
[INFO] Try to attach process 27575
[INFO] Attach process 27575 success.
[INFO] arthas-client connect 127.0.0.1 3658
  ,---.  ,------. ,--------.,--.  ,--.  ,---.   ,---.
 /  O  \ |  .--. ''--.  .--'|  '--'  | /  O  \ '   .-'
|  .-.  ||  '--'.'   |  |   |  .--.  ||  .-.  |`.  `-.
|  | |  ||  |\  \    |  |   |  |  |  ||  | |  |.-'    |
`--' `--'`--' '--'   `--'   `--'  `--'`--' `--'`-----'

wiki       https://arthas.aliyun.com/doc
tutorials  https://arthas.aliyun.com/doc/arthas-tutorials.html
version    4.0.2
main_class
pid        27575
time       2024-11-02 22:28:37.037

在线追踪CPU占比高的代码段

从官方文档可以看到Arthas可以帮助定位到cpu飙高的代码段。

具体如何操作呢?

可以关注一下这个命令:
thread

展示当前最忙的前 N 个线程并打印堆栈(
https://arthas.aliyun.com/doc/thread.html
)

$ thread -n 3
"C1 CompilerThread0" [Internal] cpuUsage=1.63% deltaTime=3ms time=1170ms

"arthas-command-execute" Id=23 cpuUsage=0.11% deltaTime=0ms time=401ms RUNNABLE
    at java.management@11.0.7/sun.management.ThreadImpl.dumpThreads0(Native Method)
    at java.management@11.0.7/sun.management.ThreadImpl.getThreadInfo(ThreadImpl.java:466)
    at com.taobao.arthas.core.command.monitor200.ThreadCommand.processTopBusyThreads(ThreadCommand.java:199)
    at com.taobao.arthas.core.command.monitor200.ThreadCommand.process(ThreadCommand.java:122)
    at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl.process(AnnotatedCommandImpl.java:82)
    at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl.access$100(AnnotatedCommandImpl.java:18)
    at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl$ProcessHandler.handle(AnnotatedCommandImpl.java:111)
    at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl$ProcessHandler.handle(AnnotatedCommandImpl.java:108)
    at com.taobao.arthas.core.shell.system.impl.ProcessImpl$CommandProcessTask.run(ProcessImpl.java:385)
    at java.base@11.0.7/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
    at java.base@11.0.7/java.util.concurrent.FutureTask.run(FutureTask.java:264)
    at java.base@11.0.7/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304)
    at java.base@11.0.7/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
    at java.base@11.0.7/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
    at java.base@11.0.7/java.lang.Thread.run(Thread.java:834)


"VM Periodic Task Thread" [Internal] cpuUsage=0.07% deltaTime=0ms time=584ms

上面展示了cpu最高的三个线程。

通过这种方式我们就可以定位到到cpu飙高的代码段。(这里是示例,具体项目案例这里就不粘贴了~)

代码重构,星夜上线,稳了

通过这个工具相对比较精准的定到了导致cpu飙高的代码片段。

进一步进入代码发现,是因为这里有一个接口,包含了一个分页查询,在返回数据的时候,需要对数据进行了包装。
这里的代码逻辑如下:
遍历循环,查询数据库,然后计算了一个数据赋值给某个扩展字段。

如果是普通接口,数据量不大,也不会有什么问题。

但是,这里是IM群里会话接口,在某一个瞬间(比如,大量用户同时登录软件),拉去IM群里的会话列表,所以这里的代码逻辑就会导致cpu飙高。

Note: 本项目类似企业微信的IM群聊,但是没有使用本地数据库,聊天数据从接口实时拉取。

于是,快速重构了这段代码,星夜上线。

至此,该问题就解决了。

结语

哪有什么岁月静好,总有人在看不到地方为你负重前行。

所谓的"技术好",不是单纯的卖弄技术,而是能够针对灵活多变的场景,恰到好处的运用技术。

活到老,学到老。

在这个过程中,我们要保持对技术的敬畏,不断学习,不断进步。

善于使用工具来解决问题,让我们的生活更加美好。

这里笔者只根据个人多年的工作经验,一点点思考和分享,抛砖引玉,欢迎大家怕批评和斧正。

参考



探头与变送器

前面的文章已经实现了ModBus客户端与服务端和他们之间的通信。但只是软件不够,毕竟传感器是硬件。
经过我的了解,一个完整的实现了Modbus协议的,并且通过RS485电缆与电脑交换
ModBusRTU
报文的Modbus设备,一般由两个部分组成。

  • 探头
    探头就是将物理量转化为电信号的东西。比如一个测温电阻,温度变化时电阻会改变。这个
    电阻/电压
    的变化情况可以测得。

  • 变送器
    变送器根据探头传过来的电信号,进行转换和存储,并且给响应电脑
    ModBusRTU
    报文。所以变送器左边通过
    火线与零线
    连接探头,右边通过
    RS485电缆
    连接电脑。内部有一个微型处理器,负责处理探头电信号,查表或者通过曲线得到物理量的值,存储在自身的寄存器中,然后响应报文。

购买设备

RS485转USB转换器

由于我的电脑只有USB接口,没有RS485接口,所以我需要买一个转换器。该设备大概30元,右边是RS485接口,中间是一个芯片,左边是一个USB接口,可以直插电脑。比较令我意外的是,USB接口的访问也是通过串口进行的。

image

TH10S-B_RS485通讯型温湿度变送器

该设备40元,是一个探头、变送器一体化集成的设备。上面的金属片是温湿度探头,具体的物理原理我没有了解。紧连着金属片后边导线扁平部分就是变送器。服务端程序就在那里。

image

探头大概几块钱,但变送器贵一点。因为探头的物理特性不同,肯定要和特定型号的变送器适配,才能保证物理量与电信号量的值一一对应,要么就要变送器可以配置这种对应关系。一体化的设备减去了这种麻烦,只需要直接接到转换器上即可。

  • 使用说明书
    image

设备连接

其中
绿色

黄色
线是双工的485信号线,用来传输ModbusRTU数据包。

image

  • 驱动安装
    这个地址是店铺客服发送的。USB转485转换器需要安装驱动才能使用,插上后电脑设备管理器中增加了一个串口
    驱动地址
    image

image

测试

把转换器插上电脑后,打开客户端软件,多了一个
COM7
串口。

image

这个变送器和客户端里面还支持设置设备地址。比如我们买了好几个这个设备时,每个设备的初试地址都是默认的
1
,因此当我们把这些设备接在一条485总线上时,就无法区分不同设备了。所以就需要我们到一个设备一个设备的连接电脑,设置不同的地址,之后再统一连到总线上,实现多个设备的访问。
我询问了我们部门的嵌入式工程师,他说公司做的还不能动态设置地址,采取的是重新编译程序再刷到设备上。

然后开始测试设备。点击连续读取后,我再用双手把传感器捂住,温度和湿度都上升很快很快。

image

没有读取在读取数据时,转换器上面的指示灯熄灭。每读取一次,闪烁一次。点击客户端软件的连续读取时,指示灯会连续闪烁。

image

监控主机

实际上还存在一种叫监控主机的东西,可以上面有一到两个网口、多个485串口,以此实现电脑远程通过光纤网络,经过监控主机,访问现场ModBus设备。在井工煤矿中这个设备大量使用。但是对于这个设备,我还缺乏了解。

本章目标

  • 使用Blazor WebAssembly实现管理“贴纸”页面
  • 集成认证与授权机制

如果你对Blazor WebAssembly的使用不感兴趣,可以跳过本章的阅读。你也可以使用自己熟悉的前端技术完成案例的界面部分,之前我们开发的后端API比较简单,所以自己实现一套前端界面并不会是一个困难的事情。

完成本章内容后,我们会得到下面的效果(点击查看大图),是不是跟第一章中所画的概念图已经很接近了?

我们到哪里了?

在进一步介绍后续内容之前,先看看目前实现了哪些内容。回顾之前的一张架构简图(其实也不算是架构图),彩色部分是目前我们已经实现的部分,虽然目前有些地方还并不完善,只是在开发环境能够正常运行起来,并且我们开发的RESTful API都还没有容器化。

本章会完成“Sticker前端应用”这个部分,在完成这部分内容后,我们就可以在开发环境中调试运行整个应用程序了,由于还没有引入基于nginx的API网关,所以,整个系统的结构跟上图相比还是会有些差异。

Blazor WebAssembly是什么?

如果问ChatGPT,它的回答是这样的:Blazor WebAssembly是一个基于WebAssembly的现代Web应用程序框架,由微软开发。它允许开发人员使用C#和.NET技术构建客户端Web应用程序,而无需使用JavaScript。Blazor WebAssembly利用WebAssembly的性能优势,将C#代码编译为WebAssembly字节码,从而在浏览器中运行高性能的客户端应用程序。开发人员可以使用Blazor组件模型构建交互式和动态的用户界面,同时利用.NET的强大功能和生态系统。Blazor WebAssembly还支持与服务器端Blazor应用程序的通信,以及与现有JavaScript库的集成,为开发人员提供了灵活和强大的工具来构建现代的Web应用程序。

Blazor应用程序基本上可以分为两种:

  • Blazor服务端应用:它基于ASP.NET Core基础设施实现服务端Hosting,并通过一种通信方式(比如SignalR)实现用户交互
  • Blazor WebAssembly:它是在客户端浏览器中运行的Web应用程序,它将C#代码编译为WebAssembly字节码,直接在浏览器中执行。Blazor WebAssembly应用程序完全在客户端执行,可以实现更快的加载速度和更高的性能,适用于需要在客户端独立运行的应用程序,以及对实时性要求较高的应用

从.NET 8开始,Visual Studio引入新的Blazor应用程序构建模板:Blazor Web App,它整合了Blazor服务端和Blazor WebAssembly的优势,并且利用了.NET 8中新引入的Blazor相关功能,比如静态服务端渲染(static SSR)、流式渲染(Streaming Rendering)等。原有的Blazor Server App和Blazor WebAssembly Standalone App在.NET 8 中仍然支持,只不过可以考虑将这些类型的应用迁移到Blazor Web App上。详见:
https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-7.0#new-blazor-web-app-template

本系列文章案例代码选用Blazor WebAssembly项目模板作为基础进行开发。

为什么选择Blazor WebAssembly?

现在前端技术非常成熟,体系也很庞大,为何抛弃React、Angular、Vue这些前端框架不选,偏偏选择Blazor WebAssembly呢?我想大概几个方面吧:

  • 我想尝试一下微软原生WebAssembly的开发框架,看看开发者体验如何
  • 我打算仍然选择微软技术来展示案例(之前有读者对我使用Java系的Keycloak有质疑,其实Keycloak在整个案例架构中只是一个IdP,它跟PostgreSQL、Redis这些组件等价,这么说能理解吧?)
  • 我对C#技术栈更为熟悉,功能开发和问题调查都会更加方便快捷,而且不容易出错。在微服务的开发模式中,技术选择其实跟团队成员的偏好也有一定的关系,在能够满足各种功能性和非功能性需求的前提下,团队当然希望采用更为熟知的技术来完成研发。聊到我的前端技术,我个人对Angular比较熟悉,因为之前做过Angular的前端项目,React和Vue一直都没有机会实践(或许我也不应该再“卷”下去了)

除了微软的Microsoft Learn和在线教育平台Edink之外,还是有
不少站点是基于Blazor技术构建
的,微软官方也给了
几个客户案例
,它们大多数都是US的公司,国内很少使用。

从上面三点可以看到,我在这个案例中选择Blazor WebAssembly,主观因素更多一些,在实际项目中,大概率大家也不会选择Blazor WebAssembly来构建自己的前端应用,原因也会是多方面的。由于本系列文章所介绍的案例比较简单,前端部分暂时也不会有特别高的要求,所以我就基于自己的主观需求,选择了Blazor WebAssembly。读者完全可以基于本案例的服务端代码,使用自己熟悉的前端技术来重制“贴纸墙”的前端部分。

构建Stickers.Web应用

首先就是创建一个Blazor WebAssembly的应用,并启用认证机制,因为后面需要集成认证和授权流程。此外,我还在项目中使用了
Blazor Bootstrap
组件库,这个组件库对主要的Bootstrap组件进行了封装,并让其在Blazor应用中完美运行。使用Blazor Bootstrap需要有一些配置工作,这里不多介绍了,官方文档有
Get Started
操作流程。

Blazor WebAssembly的开发过程这里也不多做介绍了,请直接参考本文的源代码。这里主要介绍三个话题:自定义组件、使用HttpClient访问后端服务,以及认证与授权。

自定义组件

通常我们会把一些能够重复使用的前端代码封装成一个组件,并通过参数来接受数据并定制业务逻辑,执行过程中又通过事件与其它组件交互。比如,一个分页功能就可以封装成一个组件,它可以通过参数来设置分页按钮的样式以及一次展现多少个分页按钮,当用户点击某个页码时,它又以事件的方式通知相关的其它组件(比如父页面)被点击的页码数,以便触发页面更新等后续操作。

下面的代码是案例中的“编辑贴纸”的组件,这个组件有一个参数:
StickerEditModel
,用来指定用户操作行为类型(新建/编辑)以及将要新建/被编辑贴纸的数据模型,此外还包含两个事件:
OnCloseClickCallback

OnSaveClickCallback
,当组件界面上的“关闭”和“保存”按钮被点击时,会触发这两个事件。
StickerEditModel
的定义如下:

public enum EditMode
{
    Create,
    Edit
}

public class StickerEditModel
{
    public string? Content { get; set; }
    public int Id { get; set; }
    public string? Title { get; set; }
    public EditMode EditMode { get; set; }
}

StickerEditModel
看起来跟
Sticker
业务对象很像,但它只关注界面上所需的数据,所以,在
StickerEditModel
中,并没有
CreatedOn

ModifiedOn
这些属性,因为这些属性都是在创建或者修改贴纸时由系统自动生成的,新建/编辑贴纸的界面上并不需要这些信息。以下是“编辑贴纸”的组件
EditStickerComponent
的代码:

@using Stickers.Web.ViewModels

@if (Model is not null)
{
    <div class="mb-3">
        <input @ref="_txtTitleRef" type="text" class="form-control" placeholder="请输入贴纸标题" @bind-value="Model.Title">
    </div>
    <div class="mb-3">
        <InputTextArea class="form-control" placeholder="请输入贴纸内容" @bind-Value="Model.Content"/>
    </div>

    <div class="d-grid gap-2 d-md-flex justify-content-md-end mt-2">
        <Button Color="ButtonColor.Secondary" @onclick="OnCloseClickCallback"> 取消 </Button>
        <Button Color="ButtonColor.Primary" @onclick="OnSaveClick"> 保存 </Button>
    </div>
}

@code {
    [Parameter]
    public StickerEditModel? Model { get; set; }

    [Parameter]
    public EventCallback<MouseEventArgs> OnCloseClickCallback { get; set; }

    [Parameter]
    public EventCallback<StickerEditModel> OnSaveClickCallback { get; set; }

    private ElementReference? _txtTitleRef;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (_txtTitleRef.HasValue)
        {
            await _txtTitleRef.Value.FocusAsync(true);
        }
    }

    private async Task OnSaveClick()
    {
        await InvokeAsync(() => OnSaveClickCallback.InvokeAsync(Model));
    }
}

它提供了两个文本框,分别让用户输入贴纸的标题和内容,还有两个按钮,让用户保存所做的修改或者取消所做的操作。调用组件会生成一个StickerEditModel的实例,通过Model参数传入这个组件,然后以对话框的形式显示该组件以接收用户输入,当用户完成操作并点击保存或者取消按钮时,通过事件将用户的输入返回给调用方。

使用HttpClient访问后端服务

在Blazor WebAssembly中访问后端服务是非常方便的,只需在
Program.cs
中加入HttpClient的支持,比如:

builder.Services.AddHttpClient(
    "myHttpClient", 
    client => client.BaseAddress = new Uri("http://localhost:5000")
);

然后,在Razor页面或者组件中,通过注入
HttpClientFactory
,就可以使用注册的HttpClient了:

@inject IHttpClientFactory HttpClientFactory

@code {
    private override async Task OnInitializedAsync()
    {
        // ...
        using var httpClient = HttpClientFactory.GetClient("myHttpClient");
        var responseMessage = await httpClient.GetAsync("api/any-api");
        // ...
    }
}

HttpClient在Blazor中的使用,跟ASP.NET Core中非常类似,可以直接阅读
官方文档
来了解详细内容,这里就不多做介绍了。

认证与授权


Stickers.Web
项目中需要调用后端的
Stickers.WebApi
RESTful API来实现其功能,而后端API是需要认证和授权的,所以,前端界面在HttpClient发送API调用请求时,就需要把access token带上,否则API调用是不会成功的。在Blazor WebAssembly中,要实现这个逻辑,就需要自定义
DelegatingHandler
,然后在HttpClient中使用这个自定义的Handler。

Blazor WebAssembly支持一种称之为
AuthorizationMessageHandler

DelegatingHandler
,它可以直接拿来使用,以便将access token附加到发出的HTTP请求上。只需要在添加HttpClient的时候,指定HttpMessageHandler即可:

builder.Services.AddHttpClient(
    "myHttpClient", 
    client => client.BaseAddress = new Uri("http://localhost:5000")
).AddHttpMessageHandler<AuthorizationMessageHandler>();

认证用户可以登录站点,并不表示该用户可以访问所有的页面并进行所有的操作,比如前文中所创建的nobody用户,它只能被认证,却没有任何授权,所以,对于该用户而言,它是无法使用“贴纸”功能的。在这个用户登录之后,即便登录没有问题,使用该用户的access token去访问后端API服务仍然会得到
403 Forbidden
的错误,比如,在这个用户点击“我的贴纸墙”页面时,下面的代码就会抛出未授权异常:

@code {
    protected override async Task OnInitializedAsync()
    {
        CurrentPage = await ReadStickersAsync();
        // 此处由于异常未被处理,造成页面出错
        await base.OnInitializedAsync();
    }

    private async Task<StickersPage?> ReadStickersAsync(
        int pageNumber = 1,
        int pageSize = DefaultPageSize)
    {
        using var httpClient = HttpClientFactory.CreateClient("stickersHttpClient");
        var httpResponseMessage = await httpClient
            .GetAsync($"api/stickers?page={pageNumber}&size={pageSize}");
        httpResponseMessage.EnsureSuccessStatusCode(); // 此处抛出异常
        var responseJson = await httpResponseMessage.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<StickersPage>(responseJson);
    }
}

解决这个问题的思路有两种:

  1. 由于WebAssembly是可以得到用户的access token的,所以也可以像之前Stickers API里设计的那样,获得用户的授权信息,然后根据用户的授权信息来设计前端的授权机制(Blazor WebAssembly默认基于角色授权,也可以自己开发自定义的Policy来实现更为灵活的授权方案),再根据这套机制和用户本身的授权信息以判定某个组件是否应该显示、是否可以被该用户使用
  2. 简单粗暴,在调用API时,如果异常,则捕获异常并直接跳转到登录界面或者错误界面,提示用户没有权限

第一种方案其实更为合理,一方面如果用户本来就没有权限,那就可以直接把不可以访问的组件隐藏掉或者禁用,没必要等到用户点击的时候才报错;另一方面,设计一个前端授权机制也会使得组件和页面的访问控制变得更为灵活,如果设计合理,还可以跟Blazor WebAssembly的授权机制无缝整合,大大减少出错的几率。而第二种方案则相对简单一些,适用于像本文这样的demo场景(Blazor应用的授权设计不是本案例的重点)。

首先可以自定义一个
AuthorizationMessageHandler
,然后通过
AddHttpMessageHandler
方法,将这个Handler注册到HttpClient上:

public class StickersMessageHandler : AuthorizationMessageHandler
{
    private readonly NavigationManager _navigationManager;
    
    public StickersMessageHandler(IAccessTokenProvider provider, NavigationManager navigation) : base(provider,
        navigation)
    {
        _navigationManager = navigation;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        try
        {
            var responseMessage = await base.SendAsync(request, cancellationToken);
            if (responseMessage.StatusCode == HttpStatusCode.Forbidden)
            {
                _navigationManager.NavigateTo("/forbidden");
            }
            return responseMessage;
        }
        catch (AccessTokenNotAvailableException ex)
        {
            ex.Redirect();
            return new HttpResponseMessage();
        }
    }
}

这个类首先注入一个
NavigationManager
实例,然后在重载的
SendAsync
方法中,判断返回的状态码是否为
403 Forbidden
,如果是的话,就直接跳转到/forbidden页面就可以了。这里的代码虽然对状态码进行了判断,但是在调用端的
EnsureSuccessStatusCode
方法仍然会因为状态码不是2XX而抛出异常。这里只要稍微处理一下就可以了:

protected override async Task OnInitializedAsync()
{
    try
    {
        CurrentPage = await ReadStickersAsync();
    }
    catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Forbidden)
    {
        return;
    }
    
    await base.OnInitializedAsync();
}

总结

本文简单介绍了基于Blazor WebAssembly实现前端的几个主要方面,前端代码很多,一篇文章也无法全部介绍完整,有兴趣的读者请直接下载源码阅读。在运行本案例的过程中,你会发现,登录用户之前还能互相看到对方所创建的贴纸,这是一个bug,在下一讲中,我将通过引入多租户的初步设计,将这个bug修复掉。

源代码

本章源代码在chapter_5这个分支中:
https://gitee.com/daxnet/stickers/tree/chapter_5/

下载源代码前,请先删除已有的
stickers-pgsql:dev

stickers-keycloak:dev
两个容器镜像,并删除
docker_stickers_postgres_data
数据卷。

下载源代码后,进入docker目录,然后编译并启动容器:

$ docker compose -f docker-compose.dev.yaml build
$ docker compose -f docker-compose.dev.yaml up

现在就可以直接用Visual Studio 2022或者JetBrains Rider打开stickers.sln解决方案文件,然后同时启动Stickers.WebApi和Stickers.Web两个项目进行调试运行了。

  • -t
    (或
    --tag
    )参数:用于给构建的镜像指定标签(tag)。标签的格式通常是
    [仓库名/][用户名/]镜像名:版本号
  • -f
    (或
    --file
    )参数: 指定构建镜像所使用的
    Dockerfile
    的路径。默认情况下,
    docker build
    会在当前目录下查找名为
    Dockerfile
    的文件,但通过这个参数可以指定其他位置的
    Dockerfile
  • --build - arg
    参数: 在构建过程中传递参数给
    Dockerfile
    。在
    Dockerfile
    中可以使用
    ARG
    指令来接收这些参数,这样可以使镜像构建更加灵活,例如可以根据不同的参数构建具有不同配置的镜像。例如
    Dockerfile
    如下:
       ARGAPP_ENVRUN if [ "$APP_ENV" = "production"]; then \
echo
"这是生产环境配置"; \
elif [
"$APP_ENV" = "development"]; then \
echo
"这是开发环境配置"; \
else \
echo
"未定义环境"; \
fi

Avalonia跨平台上位机控件开发之水泵

随着国产化的推进,越来越多的开发者选择使用跨平台的框架来创建上位机应用,而Avalonia正是一个优秀的选择。本文将探讨如何利用Avalonia框架进行水泵控件的开发,并重点记录在开发的过程中所碰到的一些问题。

控件的构成

水泵控件主要在控件的内部需要创建一个旋转的动画来表示水泵当前的运行状态,在没有工作时为静止状态没有任何动画,当水泵启动时会对中间的部分进行连续的旋转。运行效果如下:

动画的创建

在控件中为了方便控制旋转动画的编写,我这边使用了一个Canvas将需要旋转的部分包裹在其中,这样在制作动画的时候直接对这个Canvas进行旋转就可以了,否则要旋转部分的Path的坐标数据为相对于最外层Canvas的坐标进行绘制,此时要找到旋转中心是一个很困难的事情。同时为这个Canvas指定了一个名称,方便后续在制作动画时的Selector处理。

<Canvas
        Canvas.Left="19.5"
        Canvas.Top="39.5"
        Height="34.5"
        Name="BumpCircle"
        UseLayoutRounding="False"
        Width="34.2">
        <Path
                Fill="#ffffffff"
                Stroke="#ff6c6c6c"
                StrokeJoin="Miter"
                StrokeThickness="2.05166">
                      <Path.Data>
                      		<PathGeometry Figures="M 11.3573 15.4557 C 6.78011 13.3704 4.04416 9.6096 3.14943 4.1733 M 11.3573 19.7314 C 6.82241 21.3797 2.37649 20.6383 -1.98048 17.5071 M 15.4612 22.6354 C 13.3753 27.2112 9.61334 29.9464 4.17541 30.8408 M 19.7381 22.6354 c 1.64889 4.53351 0.907239 8.97809 -2.22494 13.3338 m 5.12991 -17.4365 c 4.57717 2.08531 7.31313 5.84611 8.20786 11.2824 M 22.6431 14.2571 c 4.53487 -1.64839 8.98079 -0.906966 13.3378 2.22428 M 18.5392 11.353 c 2.08594 -4.5758 5.84787 -7.31093 11.2858 -8.20539 m -15.5627 8.20539 c -1.64889 -4.53351 -0.907239 -8.97809 2.22494 -13.3338 m 4.50314 14.9864 c 2.20369 2.20302 2.20369 5.77484 0 7.97786 c -2.20369 2.20302 -5.77658 2.20302 -7.98027 0 c -2.20369 -2.20302 -2.20369 -5.77484 0 -7.97786 c 2.20369 -2.20302 5.77658 -2.20302 7.98027 0 m 9.43124 -9.4284 c 7.41241 7.41018 7.41241 19.4245 0 26.8347 c -7.41241 7.41019 -19.4304 7.41019 -26.8428 0 c -7.41241 -7.41018 -7.41241 -19.4245 0 -26.8347 c 7.41241 -7.41019 19.4304 -7.41019 26.8428 0" />
                          </Path.Data>
            </Path>
</Canvas>

动画的部分定义了一个伪类running,用来表示当前水泵已经处在了运行状态。因此在进行动画编写的时候先使用一下Selector选择伪类为running

<Style Selector="controls|Pump.running">

然后需要选择到我们需要需要进行动画的部分,使用如下的Selector进行选择

<Style Selector="^ /template/ Canvas#BumpCircle">

^ : 继承上一层Selector
/template/ :进入到控件内部的Template
Canvas#BumpCircle:选择名字为BumpCircle的Canvas

使用Selector选择到需要动画的控件部分后就可以进行动画的编写,动画的完整代码如下:

<Style Selector="controls|Pump.running">
  <Style Selector="^ /template/ Canvas#BumpCircle">
    <Style.Animations>
      <Animation Duration="0:0:5" IterationCount="INFINITE">
        <KeyFrame Cue="0%">
          <Setter Property="RotateTransform.Angle" Value="0" />
        </KeyFrame>
        <KeyFrame Cue="100%">
          <Setter Property="RotateTransform.Angle" Value="360" />
        </KeyFrame>
      </Animation>
    </Style.Animations>
  </Style>
</Style>

以上代码创建了一个0到360度的旋转动画。

踩到的坑

创建伪类时的一个小知识点

当在avalonia中使用伪类的时候经常会碰到:pointerover等内置的一些伪类,而要使用Selector来选择这些伪类的时候通常我们会使用

<Style Selector="Button:pointerover"/>

但是当我们定义了一个自定义的伪类时,比如上面控件用到的running,此时我们在使用Selector的时候需要使用如下格式

<Style Selector="Pump.running"/>

在平时的开发中要注意以上的一些不同

在属性改变时为控件添加伪类

为了表示控件的运行状态,我在控件的C#代码中添加了一个名为Running的StyledProperty,用这个属性来区分水泵的运行状态,因此我需要在Running属性进行修改的时候为控件添加或移除running伪类。而为了方便测试我将这个Running绑定至了Checkbox的IsChecked属性,代码如下:

<CheckBox
          Canvas.Left="600"
          Canvas.Top="250"
          Name="CheckBox">
  运行状态
</CheckBox>

<controls:Pump
               Canvas.Left="600"
               Canvas.Top="100"
               Height="100"
               Running="{Binding #CheckBox.IsChecked}"
               Width="100" />

开始时我将伪类的创建与移除代码编写在Runing属性的set中进行处理,代码如下:

    public bool? Running
    {
        get => GetValue(RunningProperty);
        set
        {
            SetValue(RunningProperty, value);
            if (value == true)
            {
                PseudoClasses.Add("running");
            }
            else
            {
                PseudoClasses.Remove("running");
            }
        }
    }

但是以上的代码在CheckBox的IsChecked属性变更时并没有办法触发到set方法,导致动画无法被触发。

经过一段时间的摸索正确的做法为在OnPropertyChanged方法中进行相关的处理,代码如下:

    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
    {
        base.OnPropertyChanged(change);

        if (change.Property == RunningProperty)
        {
            if (change.NewValue != null && (bool)change.NewValue)
            {
                PseudoClasses.Add("running");
            }
            else
            {
                PseudoClasses.Remove("running");
            }
        } 
    }

总结

在本文中,我们探讨了如何利用Avalonia框架开发水泵控件,并详细介绍了控件构成、动画创建及开发过程中的一些问题和解决方案。通过使用Canvas包裹可旋转部分,我们简化了动画实现,同时利用伪类管理水泵的运行状态,使得控件的交互性和视觉效果得到了有效提升。

我们还分享了在创建自定义伪类时的一些注意事项,以及如何在属性变更时动态更新伪类的实现。这个过程强调了Avalonia框架在构建灵活、跨平台的用户界面方面的强大能力。

希望这篇文章能够为开发者在使用Avalonia进行控件开发时提供一些实用的参考和启发,助力他们更好地实现工业自动化中的各类应用。无论是初学者还是经验丰富的开发者,都可以在这个过程中获得新的见解和技巧。让我们共同期待在Avalonia上实现更多的创意与创新!

控件的完整代码由于内容较多,请至文章底部的控件代码链接自行获取。

欢迎关注我的公众号“
nodered-co
”,原创技术文章第一时间推送。

Style Selector Syntax

How To Bind to a Contro

控件代码