2024年7月

大家好,我是独孤风。在当今数据驱动的商业环境中,数据治理成为企业成功的关键因素之一。本文我们详细探讨下为什么需要数据血缘,并说明数据血缘如何帮助企业解决关键问题,特别是在不同行业中的实际应用。
本文为《数据血缘分析原理与实践 》一书读书笔记,部分观点参考自书中原文,如需更详细的了解学习,请大家支持原作者的辛苦付出。
思维导图如下所示:

数据血缘在现代数据治理中扮演着至关重要的角色,但其实现过程却面临诸多挑战。首先,数据安全与共享是数据血缘面临的主要挑战之一。特别是在互联网行业,数据安全问题尤为严峻,数据泄露和不合规操作会给企业带来巨大风险。数据血缘分析需要追踪数据的来源和变更,确保数据操作符合安全和合规要求,从而减少数据泄露和不合规操作的风险。此外,数据共享和互通能力的不足也增加了数据管理的复杂性,需要通过数据血缘实现不同系统之间的数据共享和整合。
数据血缘在现代数据治理中扮演着不可或缺的角色。数据血缘,是指记录和追踪数据从源头到最终使用的路径和过程。它不仅能提供数据的透明度,还能揭示数据流转过程中发生的所有变更。首先,数据血缘在数据治理和决策支持方面具有重要意义。通过清晰的数据流转路径,企业能够更好地理解数据的来源、流转和变化情况,从而提升数据治理效果。互联网行业的数据安全问题尤为严重,数据泄露和不合规操作会给企业带来巨大风险。数据血缘分析能够帮助企业追踪数据的来源和变更,确保数据操作符合安全和合规要求,减少数据泄露和不合规操作的风险。这种透明度不仅有助于提升企业的数据治理能力,还能支持更准确的业务决策,避免数据孤岛和重复建设,提高整体数据利用效率。

数据血缘在数据质量管理中同样具有重要作用。在零售行业,数据分析和精准营销越来越依赖于高质量的数据。通过追踪数据的整个生命周期,数据血缘能够帮助企业识别数据质量问题的根源,并进行有效的监控和管理。数据血缘确保数据的一致性和完整性,从而提升数据的可信度。在实际应用中,零售企业可以通过数据血缘追踪客户行为数据,从而优化营销策略,提升客户满意度和销售额。此外,数据血缘还能够快速评估数据变更的影响范围。在企业进行系统升级或数据迁移时,数据变更可能会影响多个系统和流程。通过数据血缘分析,企业可以迅速识别受影响的系统和流程,进行及时调整,减少错误和风险。这种能力在制造行业尤为重要,制造企业需要确保生产数据的准确和稳定,以保证产品质量和生产效率。数据血缘在数据资产管理中起着关键作用。在能源化工行业,数据共享和互通能力不足,影响了企业对数据资产的有效利用。数据血缘帮助企业识别和管理数据资产,提升数据的利用效率和价值。通过数据血缘,企业可以更好地挖掘数据的潜在价值,支持业务创新和增长。比如,能源化工企业可以通过数据血缘追踪设备运行数据,优化设备维护策略,降低运营成本,提高设备利用率。此外,数据血缘在数据安全和合规性管理中具有重要作用。随着数据隐私和安全法规的日益严格,企业需要确保数据操作符合相关法律法规。数据血缘记录了数据的使用情况,帮助企业实现合规性管理和数据安全保护。通过数据血缘,企业可以确保数据的使用符合相关法律法规,防止数据滥用和泄露。例如,金融行业的企业可以通过数据血缘追踪客户数据的使用情况,确保合规操作,保护客户隐私。
数据血缘在现代数据治理中具有重要意义。它不仅能够解决数据质量、数据孤岛、数据变更影响评估、数据合规和安全管理以及数据价值评估等关键问题,还能提升企业的数据治理能力和效率。通过系统的实施和持续优化,企业可以实现数据的高效管理和利用,提升业务竞争力。《数据血缘分析原理与实践》这本书详细介绍了数据血缘的理论和实践方法,为企业和数据从业者提供了宝贵的指导。通过学习和实践数据血缘,企业可以更好地应对数据管理中的挑战,实现数据的最大价值。

开心一刻

昨天看到一条广告:全国比丑大赛,一等奖 2 万奖金

我高高兴兴骑着自行车去了场地,结果被保安拦着不让进

我心里窃喜:莫非我长得不丑,不符合参赛条件?

我说道:为什么不让我进

保安:这是业余人员间的比赛,职业选手不能参加

敢情不是不丑,而是太丑!

书接上回

上篇
不单独部署注册中心,又要具备注册中心的功能,咋不让我上天?

说道
任务分发中心
既集成了
Eureka Server
,还集成了
Eureka Client
,实现了对
任务执行器
的负责均衡,已经很完美的实现了需求

有两个服务:任务分发中心、任务执行器
分发中心负责任务拆分,然后将拆分后的任务下发给执行器,执行器执行任务
任务分发中心和任务执行器都要支持水平扩节点

当我向领导反馈我的实现时,得到了我想要的肯定

但又给我引申了需求,他说

  1. 任务分发中心只调用一个服务:任务执行器,引入
    open-feign
    感觉没什么必要
  2. Eureka Server
    肯定是存储了
    任务执行器
    的实例,是不是可以不开启
    @EnableEurekaClient
    (言外之意就是任务分发中心不要做
    Eureka Client

虽然我心中一万只草泥马奔腾而过,十分不愿意去修改,但理性告诉我他是对的,是可以继续简化(人家能当领导确实是有两把刷子的!)

我们分析下,如果我们拿到了
任务执行器
列表,那么我们就可以根据负载均衡策略选择其中某个实例进行任务分发,所以重点是如何获取
任务执行器
列表

获取服务实例列表

如何获取
任务执行器
列表,大家就不要想着
@EnableEurekaClient
了,因为引申需求的第 2 点就限制了不能用
EurekaClient
,那怎么办?

我就不绕圈子了,我们需要用到一个
holder

EurekaServerContextHolder
,借此我们就能拿到
任务执行器
实例列表,例如:

EurekaServerContextHolder

拿到实例列表后,该做什么了?

是不是负载均衡选择其中某个实例?

负载均衡

拿到实例列表后,我们可以自实现负载均衡策略,但是不推荐,这就跟我为什么不自实现
注册中心
是一个道理,选择现成的、成熟的负载均衡器更具性价比

不是自实现不起,而是现成更具性价比!

这次就不选择
Ribbon
了,因为我们选择的
Spring Cloud 2021.0.8
默认包含的负载均衡器是
spring-cloud-balancer

spring-cloud-loadbalancer

服务实例列表已经获取到了,负载均衡器也有了,接下来做什么?

那就是将实例列表注册给负载均衡器,如下所示

ServiceInstanceListSupplier

接下来是不是就只差任务分发了?

任务分发

我们要明确,我们的
task-executor
提供的是一个
HTTP
接口,所以我们接下来要考虑的是如何去请求这个接口

至于是请求哪个
task-executor
实例,就交给负载均衡器了

发送
HTTP
请求的方式不要太多,而
Spring

RestTemplate
就是其中之一,为了不额外引用第三方
HTTP
工具,也为了适配负载均衡器,推荐就是用
RestTemplate

RestTemplate

至此,就只差临门一脚了,那就是通过
RestTemplate
进行任务分发

任务分发

启动
task-dispatcher

task-executor
,然后调用接口

http://192.168.2.10:8080/dispatcher/job/dispatch?jobId=689

结果
success
,日志输出如下

  1. task-dispatcher

    2024-07-05 22:53:52.606|INFO|http-nio-8080-exec-9|30|com.qsl.task.web.JobController          :收到作业[jobId=689]执行请求
    2024-07-05 22:53:52.606|INFO|http-nio-8080-exec-9|31|com.qsl.task.web.JobController          :作业[jobId=689]拆分任务中...
    2024-07-05 22:53:52.606|INFO|http-nio-8080-exec-9|33|com.qsl.task.web.JobController          :作业[jobId=689]拆分完成,得到作业列表[[123, 666, 888, 999]]
    2024-07-05 22:53:52.611|INFO|http-nio-8080-exec-9|39|com.qsl.task.web.JobController          :任务[123]执行结果:success
    2024-07-05 22:53:52.614|INFO|http-nio-8080-exec-9|39|com.qsl.task.web.JobController          :任务[666]执行结果:success
    2024-07-05 22:53:52.617|INFO|http-nio-8080-exec-9|39|com.qsl.task.web.JobController          :任务[888]执行结果:success
    2024-07-05 22:53:52.619|INFO|http-nio-8080-exec-9|39|com.qsl.task.web.JobController          :任务[999]执行结果:success
    
  2. task-executor

    2024-07-05 22:53:52.609|INFO|http-nio-8081-exec-3|17|com.qsl.task.web.TaskController         :收到任务[taskId=123]执行请求
    2024-07-05 22:53:52.609|INFO|http-nio-8081-exec-3|18|com.qsl.task.web.TaskController         :任务[taskId=123]执行中...
    2024-07-05 22:53:52.610|INFO|http-nio-8081-exec-3|19|com.qsl.task.web.TaskController         :任务[taskId=123]执行完成
    2024-07-05 22:53:52.612|INFO|http-nio-8081-exec-2|17|com.qsl.task.web.TaskController         :收到任务[taskId=666]执行请求
    2024-07-05 22:53:52.613|INFO|http-nio-8081-exec-2|18|com.qsl.task.web.TaskController         :任务[taskId=666]执行中...
    2024-07-05 22:53:52.613|INFO|http-nio-8081-exec-2|19|com.qsl.task.web.TaskController         :任务[taskId=666]执行完成
    2024-07-05 22:53:52.615|INFO|http-nio-8081-exec-4|17|com.qsl.task.web.TaskController         :收到任务[taskId=888]执行请求
    2024-07-05 22:53:52.616|INFO|http-nio-8081-exec-4|18|com.qsl.task.web.TaskController         :任务[taskId=888]执行中...
    2024-07-05 22:53:52.616|INFO|http-nio-8081-exec-4|19|com.qsl.task.web.TaskController         :任务[taskId=888]执行完成
    2024-07-05 22:53:52.618|INFO|http-nio-8081-exec-1|17|com.qsl.task.web.TaskController         :收到任务[taskId=999]执行请求
    2024-07-05 22:53:52.618|INFO|http-nio-8081-exec-1|18|com.qsl.task.web.TaskController         :任务[taskId=999]执行中...
    2024-07-05 22:53:52.618|INFO|http-nio-8081-exec-1|19|com.qsl.task.web.TaskController         :任务[taskId=999]执行完成
    

目前
task-executor
是单实例,我们再启动一个实例,来看看负载均衡效果

  1. task-executor 8081

    2024-07-05 22:59:01.311|INFO|http-nio-8081-exec-6|17|com.qsl.task.web.TaskController         :收到任务[taskId=123]执行请求
    2024-07-05 22:59:01.312|INFO|http-nio-8081-exec-6|18|com.qsl.task.web.TaskController         :任务[taskId=123]执行中...
    2024-07-05 22:59:01.312|INFO|http-nio-8081-exec-6|19|com.qsl.task.web.TaskController         :任务[taskId=123]执行完成
    2024-07-05 22:59:01.391|INFO|http-nio-8081-exec-7|17|com.qsl.task.web.TaskController         :收到任务[taskId=888]执行请求
    2024-07-05 22:59:01.391|INFO|http-nio-8081-exec-7|18|com.qsl.task.web.TaskController         :任务[taskId=888]执行中...
    2024-07-05 22:59:01.391|INFO|http-nio-8081-exec-7|19|com.qsl.task.web.TaskController         :任务[taskId=888]执行完成
    
  2. task-executor 8090

    2024-07-05 22:59:01.379|INFO|http-nio-8090-exec-2|17|com.qsl.task.web.TaskController         :收到任务[taskId=666]执行请求
    2024-07-05 22:59:01.380|INFO|http-nio-8090-exec-2|18|com.qsl.task.web.TaskController         :任务[taskId=666]执行中...
    2024-07-05 22:59:01.380|INFO|http-nio-8090-exec-2|19|com.qsl.task.web.TaskController         :任务[taskId=666]执行完成
    2024-07-05 22:59:01.394|INFO|http-nio-8090-exec-1|17|com.qsl.task.web.TaskController         :收到任务[taskId=999]执行请求
    2024-07-05 22:59:01.394|INFO|http-nio-8090-exec-1|18|com.qsl.task.web.TaskController         :任务[taskId=999]执行中...
    2024-07-05 22:59:01.394|INFO|http-nio-8090-exec-1|19|com.qsl.task.web.TaskController         :任务[taskId=999]执行完成
    

至此,引申需求是不是完美实现了?

装逼

但有一点需要注意,既然
task-dispatcher
不再作为
Eureka Client
, 有个配置最好关闭

fetch-registry

完整代码:integrate-eureka2

https://gitee.com/youzhibing/qsl-project/tree/master/integrate-eureka2

总结

  1. 有存储功能的容器(如
    Eureka Server

    Spring
    )一般都会提供
    holder
    (钩子)来获取容器中的实例

  2. 尽量选同体系内的组件(比如都用
    Spring
    自带的组件),不会存在兼容性问题

  3. spring-cloud-balancer
    目前支持两种负载均衡策略


    spring-cloud-balancer 负载均衡策略

    轮询和随机,默认是轮询;如果需要其他的复杂均衡策略,则需要自定义

最新内容优先发布于个人博客:
小虎技术分享站
,随后逐步搬运到博客园。

实现了一个支持长短按得按钮组件,单击可以触发
Click
事件,长按可以触发
LongPressed
事件,长按松开时触发
LongClick
事件。源码请自取:
Github

长按阈值属性的建立

为了方便在
xaml
中使用,我们先配置一个
DependencyProperty
叫做
LongPressTime
来作为界定长按的阈值

public class LongPressButtonEx : Button
{
        public static readonly DependencyProperty LongPressTimeProperty
            = DependencyProperty.Register("LongPressTime", typeof(int),
                typeof(LongPressButtonEx), new PropertyMetadata(500));

        public int LongPressTime
        {
            set => SetValue(LongPressTimeProperty, value);
            get => (int)GetValue(LongPressTimeProperty);
        }
}

定义完成后可以在Xaml设计器中使用
LongPressTime
这个拓展属性

<Window x:Class="LongPressButton.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:LongPressButton"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <local:LongPressButtonEx Width="96" Height="48" LongPressTime="200">
            Button
        </local:LongPressButtonEx>
    </Grid>
</Window>

长按的定时器判定方法

C#中的4种定时器,在WPF中需要使用
Dispater Timer

定义一个DispatcherTimer来监控是否按下达到了长按

private DispatcherTimer _pressDispatcherTimer;

private void OnDispatcherTimeOut(object sender, EventArgs e)
{
    _pressDispatcherTimer?.Stop();
    Debug.WriteLine($"Timeout {LongPressTime}");
}

protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
    base.OnMouseLeftButtonDown(e);
    Debug.WriteLine("Button: Mouse down.");
    if (_pressDispatcherTimer == null)
    {
        _pressDispatcherTimer = new DispatcherTimer();
        _pressDispatcherTimer.Tick += OnDispatcherTimeOut;
        _pressDispatcherTimer.Interval = new TimeSpan(0, 0, 0, 0, LongPressTime);
        _pressDispatcherTimer.Start();
        Debug.WriteLine("Button: Timer started");
    }
}

protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
{
    base.OnMouseLeftButtonUp(e);
    Debug.WriteLine("Button: Mouse up.");
    _pressDispatcherTimer?.Stop();
    _pressDispatcherTimer = null;
}

现在分别点击和长按按钮可以看到调试输出

...
# 点击
Button: Mouse down.
Button: Timer started
Button: Mouse up.
# 长按
Button: Mouse down.
Button: Timer started
Timeout 200
Button: Mouse up.

实现长按事件的定义

现在作为一个自定义控件,我们需要在长按后发出一个
RoutedEvent
,并修改部分之前的代码抛出事件

/// <summary>
/// LongPress Routed Event
/// </summary>
public static readonly RoutedEvent LongPressEvent
    = EventManager.RegisterRoutedEvent("LongPress",
        RoutingStrategy.Bubble,
        typeof(RoutedEventHandler),
        typeof(LongPressButtonEx));

public event RoutedEventHandler LongPress
{
    add => AddHandler(LongPressEvent, value);
    remove => RemoveHandler(LongPressEvent, value);
}

private void OnDispatcherTimeOut(object sender, EventArgs e)
{
    _pressDispatcherTimer?.Stop();
    Debug.WriteLine($"Timeout {LongPressTime}");
    RaiseEvent(new RoutedEventArgs(LongPressEvent));    // raise the long press event
}

回到窗体的代码中,添加事件的响应

<local:LongPressButtonEx Height="48" Width="256" LongPressTime="200"
        LongPress="LongPressButtonEx_LongPress"
        Click="LongPressButtonEx_Click">
    Click or Long Press Me!
</local:LongPressButtonEx>

C#代码如下,长按按钮会显示Long Pressed,单击会是Click

private void LongPressButtonEx_LongPress(object sender, RoutedEventArgs e)
{
    if (sender is LongPressButtonEx btn)
    {
        btn.Content = "Long Pressed";
    }
}

private void LongPressButtonEx_Click(object sender, RoutedEventArgs e)
{
    if (sender is LongPressButtonEx btn)
    {
        btn.Content = "Clicked";
    }
}

image

发现
Click

LongPress
都可以响应,但是当松开按钮时又变成了
Click
,原因是鼠标松开时响应了默认的Click事件

现在对按钮控件默认的
OnClick
函数稍作修改,可以让
Click
也不出问题

/// <summary>
/// DependencyProperty for IsLongPress 
/// </summary>
public static readonly DependencyProperty IsLongPressProperty
    = DependencyProperty.Register("IsLongPress", typeof(bool),
        typeof(LongPressButtonEx), new PropertyMetadata(false));

public bool IsLongPress
{
    set => SetValue(IsLongPressProperty, value);
    get => (bool)GetValue(IsLongPressProperty);
}

private void OnDispatcherTimeOut(object sender, EventArgs e)
{
    IsLongPress = true;
    _pressDispatcherTimer?.Stop();
    Debug.WriteLine($"Timeout {LongPressTime}");
    RaiseEvent(new RoutedEventArgs(LongPressEvent));    // raise the long press event
}

protected override void OnClick()
{
    if (!IsLongPress)
    {
        base.OnClick();
    }
    else
    {
        RaiseEvent(new RoutedEventArgs(LongPressReleaseEvent));    // raise the long press event
        IsLongPress = false;
    }
}

之后再进行点击操作,我们就可以看到符合预期的结果

image

长按+Style按钮的展示效果
外观Style自定义见这篇文章:
WPF自定义按钮外形
image

参考链接

UIElement.MouseLeftButtonDown Event
用户控件自定义 DependencyProperty 属性使用教程
WPF 中 DispatcherTimer 计时器
如何:创建自定义路由事件
WPF 自定义带自定义参数路由事件
Use WPF Style in another assemble

大家好,我是码农先森。

回想起以前用模版渲染数据的岁月,那时都没有 API 接口开发的概念。PHP 服务端和前端 HTML、CSS、JS 代码混合式开发,也不分前端、后端程序员,大家都是全干工程师。随着前后端分离、移动端开发的兴起,用后端渲染数据的开发方式效率低下,已经不能满足业务对需求快速上线的要求了。于是为了前后端的高效协同开发引入了 API 接口,只要在开发需求之前约定好数据传参,之后便可以开始启动自己的开发任务且互不干涉,最后再进行统一的接口联调。

根据熵增原则,如果任何事情不加以规则来限制,则都会朝着泛滥的方式发展。同样 API 接口开发也会出现这样的情况,由于每个人的开发习惯不同,导致 API 接口的开发格式五花八门,联调过程困难重重。无规矩不成方圆,因此为了规范 API 接口开发的形式,同时也结合我平时的项目开发经验。总结了一些 API 接口开发的实践经验,希望对大家能有所帮助。

话不多说,开整!

这次主要的实践内容是 API 接口签名设计,以下是一些关键的步骤:

  • 给前端分配一个 AppKey,这个 AppKey 需要带在 HTTP Header 头中进行传输。
  • 在前端的传参中需要额外增加 时间戳 timestamp、随机字符串 nonce 参数。
  • 将前端的所有参数排序后拼接成一个字符串,再使用 MD5 加密函数生成 sign 签名字符串。
  • 服务端接收到参数后,先验证 AppKey 是否一致。
  • 再验证前端所传的时间戳参数是否还在有效期。
  • 之后在服务端使用同样的加密算法生成 sign 签名串,再与前端的 sign 签名串比对。
  • 最后判断前端所传的随机字符串是否已被使用,一次请求有效。

接下来开始在 ThinkPHP 和 Gin 框架中进行实现,文中只展示了核心的代码,完整代码的获取方式放在了文章末尾。

我们先熟悉一下项目结构核心的目录,有助于理解文中的内容。一个正常的请求首先要经过路由 route 再到中间件 middleware 最后到控制器 controller,API 接口的签名验证是在中间件 middleware 中实现,作为一个中间层在整个请求链路中起着承上启下的重要作用。

[manongsen@root php_to_go]$ tree -L 2
.
├── go_sign
│   ├── app
│   │   ├── controller
│   │   │   └── user.go
│   │   ├── middleware
│   │   │   └── api_sign.go
│   │   ├── config.go
│   │   └── route.go
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── php_sign
│   ├── app
│   │   ├── controller
│   │   │   └── User.php
│   │   ├── middleware
│   │   │   └── ApiSign.php
│   │   └── middleware.php
│   ├── composer.json
│   ├── composer.lock
│   ├── config
│   ├── route
│   │   └── app.php
│   ├── think
│   ├── vendor
│   └── .env

ThinkPHP

使用 composer 创建基于 ThinkPHP 框架的 php_sign 项目。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/php_sign
[manongsen@root php_sign]$ composer create-project topthink/think php_sign

随机字符串需要用到 Redis 进行存储,所以这里需要安装 Redis 扩展包,便于操作 Redis。

[manongsen@root php_sign]$ composer require predis/predis

在项目 php_sign 下创建 ApiSign 中间件。

[manongsen@root php_sign]$ php think make:middleware ApiSign
Middleware:app\middleware\ApiSign created successfully.

在项目 php_sign 下复制一个 env 配置文件,并且定义好 AppKey。

[manongsen@root php_sign]$ cp .example.env .env

API 接口签名的验证是放在框架的中间件中进行实现的,其中时间戳的有效时间设置的是 2 秒,有些朋友会有疑惑为什么是 2 秒?3 秒、5 秒不行吗?这里的有效时间是基于网络通信的延时考虑的,根据普遍情况延时大概是 2 秒。如果你的服务延时比较长,也可以设置长一些,并没有一个定量的值,话说到这里也提醒一下如果你的接口延时超过 2 秒,大概率需要优化一下代码了。此外,还有一个随机字符串参数,这个参数的目的是为了防止接口被重放,如果做过爬虫的朋友可能对这个会深有感触,这也是防范爬虫的一种手段。

<?php
declare (strict_types = 1);

namespace app\middleware;

use think\facade\Env;
use think\facade\Cache;

class ApiSign
{
    /**
     * 处理请求
     *
     * @param \think\Request $request
     * @param \Closure       $next
     * @return Response
     */
    public function handle($request, \Closure $next)
    {
        /*********************** 验证AppKey参数 ******************/
        $headers = $request->header();
        if (!isset($headers["app-key"])) {
            return json(["code" => 400, "msg" => "秘钥参数缺失"]);
        }
        $reqAppKey = $headers["app-key"];
        $vfyAppKey = Env::get("APP_KEY");
        if ($reqAppKey != $vfyAppKey) {
            return json(["code" => 400, "msg" => "签名秘钥无效"]);
        }

        /*********************** 验证时间戳参数 *******************/
        $params = $request->param();
        if (!isset($params["timestamp"])) {
            return json(["code" => 400, "msg" => "时间参数缺失"]);
        }
        $timestamp = $params["timestamp"];
        $nowTime = time();
        if (($nowTime-$timestamp) > 2) {
            return json(["code" => 400, "msg" => "时间参数过期"]);
        }

        /*********************** 验证签名串参数 *******************/
        if (!isset($params["sign"])) {
            return json(["code" => 400, "msg" => "签名参数缺失"]);
        }
        $reqSign = $params["sign"];
        unset($params["sign"]);
        // 将参数进行排序
        ksort($params);
        $paramStr = http_build_query($params);
        // md5 加密处理
        $vfySign = md5($paramStr . "&app_key={$vfyAppKey}");
        // 比较签名参数
        if ($reqSign != $vfySign) {
            return json(["code" => 400, "msg" => "签名验证失败"]);
        }

        /*********************** 验证随机串参数 *******************/
        if (!isset($params["nonce_str"])) {
            return json(["code" => 400, "msg" => "随机串参数缺失"]);
        }
        $nonceStr = $params["nonce_str"];

        // 判断 nonce_str 随机字符串是否被使用
        $redis = Cache::store('redis')->handler();
        $flag = $redis->exists($nonceStr);
        if ($flag) {
            return json(["code" => 400, "msg" => "随机串参数无效"]);
        }

        // 存储 nonce_str 随机字符串
        $redis->set($nonceStr, $timestamp, 2);
        return $next($request);
    }
}

启动 php_sign 服务。

[manongsen@root php_sign]$ php think run
ThinkPHP Development server is started On <http://0.0.0.0:8000/>
You can exit with `CTRL-C`
Document root is: /home/manongsen/workspace/php_to_go/php_sign/public
[Wed Jul  3 22:02:16 2024] PHP 8.3.4 Development Server (http://0.0.0.0:8000) started

使用 Postman 工具进行测试验证,通过构造正确的参数,便可以成功的返回数据。

Gin

通过 go mod 初始化 go_sign 项目。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/go_sign
[manongsen@root go_sign]$ go mod init go_sign

安装 Gin 框架库,这里与 ThinkPHP 不一样的是 Gin 框架是以第三库的形式在 gin_sign 项目中进行引用的。

[manongsen@root go_sign]$ go get github.com/gin-gonic/gin

安装 Redis 操作库,与在 ThinkPHP 框架中一样也要使用到 Redis。

[manongsen@root go_sign]$ go get github.com/go-redis/redis

这是在 Gin 框架中利用中间件来进行 API 接口签名验证,从代码量上来看就比 PHP 要多了。其中还需要自行合并 GET 和 POST 参数,方便在中间件中统一进行签名处理。对参数的拼接也没有类似 http_build_query 的方法,总体上来说在 Go 中进行签名验证需要繁琐不少。

package middleware

import (
	"bytes"
	"crypto/md5"
	"encoding/json"
	"fmt"
	"go_sign/app"
	"io/ioutil"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
)

func ApiSign() gin.HandlerFunc {
	return func(c *gin.Context) {
		/*************************** 验证AppKey参数 **************************/
		reqAppKey := c.Request.Header.Get("app-key")
		if len(reqAppKey) == 0 {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "秘钥参数缺失"})
			c.Abort()
			return
		}
		vfyAppKey := app.APP_KEY
		if reqAppKey != vfyAppKey {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "秘钥参数无效"})
			c.Abort()
			return
		}

		// 获取请求参数
		params := mergeParams(c)

		/*************************** 验证时间戳参数 **************************/
		if _, ok := params["timestamp"]; !ok {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "时间参数无效"})
			c.Abort()
			return
		}
		timestampStr := fmt.Sprintf("%v", params["timestamp"])

		timestampInt, err := strconv.ParseInt(timestampStr, 0, 64)
		if err != nil {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "时间参数无效"})
			c.Abort()
			return
		}

		nowTime := time.Now().Unix()
		if nowTime-timestampInt > 2 {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "时间参数过期"})
			c.Abort()
			return
		}

		/*************************** 验证签名串参数 **************************/
		if _, ok := params["sign"]; !ok {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "签名参数无效"})
			c.Abort()
			return
		}
		reqSign := fmt.Sprintf("%v", params["sign"])

		// 针对 dataMap 进行排序
		dataMap := params
		keys := make([]string, len(dataMap))
		i := 0
		for k := range dataMap {
			keys[i] = k
			i++
		}
		sort.Strings(keys)
		var buf bytes.Buffer
		for _, k := range keys {
			if k != "sign" && !strings.HasPrefix(k, "reserved") {
				buf.WriteString(k)
				buf.WriteString("=")
				buf.WriteString(fmt.Sprintf("%v", dataMap[k]))
				buf.WriteString("&")
			}
		}
		bufStr := buf.String()
		dataStr := bufStr + "app_key=" + app.APP_KEY

		// 进行 md5 加密处理
		data := []byte(dataStr)
		has := md5.Sum(data)
		vfySign := fmt.Sprintf("%x", has) // 将[]byte转成16进制
		if reqSign != vfySign {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "签名验证失败"})
			c.Abort()
			return
		}

		/*************************** 验证随机串参数 **************************/
		if _, ok := params["nonce_str"]; !ok {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "随机串参数缺失"})
			c.Abort()
			return
		}
		nonceStr := fmt.Sprintf("%v", params["nonce_str"])

		// 判断是否存在 nonce_str 随机字符串
		flag, _ := app.RedisConn.Exists(nonceStr).Result()
		if flag > 0 {
			c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "随机串参数无效"})
			c.Abort()
			return
		}

		// 存储nonce_str随机字符串
		app.RedisConn.Set(nonceStr, timestampInt, time.Second*2).Result()

		c.Next()
	}
}

// 将 GET 和 POST 的参数合并到同一 Map
func mergeParams(c *gin.Context) map[string]interface{} {
	var (
		dataMap  = make(map[string]interface{})
		queryMap = make(map[string]interface{})
		postMap  = make(map[string]interface{})
	)

	contentType := c.ContentType()
	for k := range c.Request.URL.Query() {
		queryMap[k] = c.Query(k)
	}

	if contentType == "application/json" {
		if c.Request != nil && c.Request.Body != nil {
			bodyBytes, _ := ioutil.ReadAll(c.Request.Body)
			if len(bodyBytes) > 0 {
				if err := json.NewDecoder(bytes.NewBuffer(bodyBytes)).Decode(&postMap); err != nil {
					return nil
				}
				c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
			}
		}
	} else if contentType == "multipart/form-data" {
		for k, v := range c.Request.PostForm {
			if len(v) > 1 {
				postMap[k] = v
			} else if len(v) == 1 {
				postMap[k] = v[0]
			}
		}
	} else {
		for k, v := range c.Request.PostForm {
			if len(v) > 1 {
				postMap[k] = v
			} else if len(v) == 1 {
				postMap[k] = v[0]
			}
		}
	}

	// 优先级:以post优先级最高,会覆盖get参数
	for k, v := range queryMap {
		dataMap[k] = v
	}
	for k, v := range postMap {
		dataMap[k] = v
	}

	return dataMap
}

启动 gin_sin 服务。

[manongsen@root go_sign]$ go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /user/info                --> go_sign/app/controller.UserInfo (4 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8001

同样也使用 Postman 工具进行测试验证,通过构造正确的参数,便可以成功的返回数据。

结语

数据安全一直是个热门的话题,API 接口在数据的传输上扮演着至关重要的角色。为了 API 接口的安全性、健壮性,完整性,往往需要将网络上的数据进行签名加密传输。同时为了防止 API 接口被重放爬虫伪造等类似恶意攻击的手段,还要在接口设计时增加有效时间、随机字符串、签名串等参数,来保障数据的安全性。这一次的 API 接口签名设计实践,大家也可以手动尝试实验一下,希望对大家的日常工作能有所帮助。最后感兴趣的朋友可以在微信公众号内回复「4867」获取完整的实践代码。


欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。

前言

vue2的时候想必大家有遇到需要在style模块中访问script模块中的响应式变量,为此我们不得不使用css变量去实现。现在vue3已经内置了这个功能啦,可以在style中使用
v-bind
指令绑定script模块中的响应式变量,这篇文章我们来讲讲vue是如何实现在style中使用script模块中的响应式变量。注:本文中使用的vue版本为
3.4.19

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们来看个简单的demo,index.vue文件代码如下:

<template>
  <div>
    <p>222</p>
    <span class="block">hello world</span>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const primaryColor = ref("red");
</script>

<style scoped>
.block {
  color: v-bind(primaryColor);
}
</style>

我们在script模块中定义了一个响应式变量
primaryColor
,并且在style中使用
v-bind
指令将
primaryColor
变量绑定到color样式上面。

我们在浏览器的network面板中来看看编译后的js文件,如下图:
index-vue

从上图中可以看到在network面板中编译后的index.vue文件有两个,并且第二个里面有一些query参数,其中的
type=style
就表示当前文件的内容对应的是style模块。第一个index.vue对应的是template和script模块中的内容。

我们来看看第一个index.vue,如下图:
setup

从上图中可以看到setup函数是script模块编译后的内容,在
setup
函数中多了一个
_useCssVars
函数,从名字你应该猜到了,这个函数的作用是和css变量有关系。别着急,我们接下来会详细去讲
_useCssVars
函数。

我们再来看看第二个index.vue,如下图:
style

从上图中可以看到这个index.vue确实对应的是style模块中的内容,并且原本的
color: v-bind(primaryColor);
已经变成了
color: var(--c845efc6-primaryColor);

很明显浏览器是不认识
v-bind(primaryColor);
指令的,所以经过编译后就变成了浏览器认识的css变量
var(--c845efc6-primaryColor);

我们接着在elements面板中来看看此时class值为block的span元素,如下图:
elements

从上图中可以看到color的值为css变量
var(--c845efc6-primaryColor)
,这个我们前面讲过。不同的是这里从父级元素div中继承过来一个
--c845efc6-primaryColor: red;

这个就是声明一个名为
--c845efc6-primaryColor
的css变量,变量的值为
red

还记得我们在script模块中定义的响应式变量
primaryColor
吗?他的值就是
red

所以这个span元素最终color渲染出来的值就是
red

接下来我们将通过debug的方式带你搞清楚在style中是如何将指令
v-bind(primaryColor)
编译成css变量
var(--c845efc6-primaryColor)
,以及
_useCssVars
函数是如何生成声明值为
red
的css变量
--c845efc6-primaryColor

doCompileStyle
函数

在前面的文章中我们讲过了style模块实际是由
doCompileStyle
函数函数处理的,具体如何调用到
doCompileStyle
函数可以查看我之前的文章:
掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染

我们需要给
doCompileStyle
函数打个断点,
doCompileStyle
函数的代码位置在:
node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js

还是一样的套路启动一个debug终端。这里以
vscode
举例,打开终端然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

在debug终端执行
yarn dev
,在浏览器中打开对应的页面,比如:
http://localhost:5173/

此时断点将停留在
doCompileStyle
函数中,在我们这个场景中
doCompileStyle
函数简化后的代码如下:

import postcss from "postcss";

function doCompileStyle(options) {
  const {
    filename,
    id,
    postcssOptions,
    postcssPlugins,
  } = options;
  const source = options.source;
  const shortId = id.replace(/^data-v-/, "");

  const plugins = (postcssPlugins || []).slice();
  plugins.unshift(cssVarsPlugin({ id: shortId, isProd }));

  const postCSSOptions = {
    ...postcssOptions,
    to: filename,
    from: filename,
  };
  let result;
  try {
    result = postcss(plugins).process(source, postCSSOptions);
    return result.then((result) => ({
      code: result.css || "",
      // ...省略
    }));
  } catch (e: any) {
    errors.push(e);
  }
}

在前面的文章
掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染
中我们讲过了,这里
id
的值为使用了
scoped
后给html增加的自定义属性
data-v-x
,每个vue文件生成的
x
都是不一样的。在
doCompileStyle
函数中使用
id.replace
方法拿到
x
赋值给变量
shortId

接着就是定义一个
plugins
插件数组,并且将
cssVarsPlugin
函数的返回结果push进去。

这里
cssVarsPlugin
函数就是返回了一个自定义的
postcss
插件。

最后就是执行
result = postcss(plugins).process(source, postCSSOptions)
拿到经过
postcss
转换编译器处理后的css。

可能有的小伙伴对
postcss
不够熟悉,我们这里来简单介绍一下。

postcss
是 css 的 transpiler(转换编译器,简称转译器),它对于 css 就像 babel 对于 js 一样,能够做 css 代码的分析和转换。同时,它也提供了插件机制来做自定义的转换。

在我们这里主要就是用到了
postcss
提供的插件机制来完成css scoped的自定义转换,调用
postcss
的时候我们传入了
source
,他的值是style模块中的css代码。并且传入的
plugins
插件数组中有个
cssVarsPlugin
插件,这个自定义插件就是vue写的用于处理在css中使用v-bind指令。

在执行
postcss
对css代码进行转换之前我们在debug终端来看看此时的css代码是什么样的,如下图:

source

从上图中可以看到此时的
options.source
中还是
v-bind(primaryColor)
指令。

cssVarsPlugin
插件

cssVarsPlugin
插件在我们这个场景中简化后的代码如下:

const vBindRE = /v-bind\s*\(/g;
const cssVarsPlugin = (opts) => {
  const { id, isProd } = opts;
  return {
    postcssPlugin: "vue-sfc-vars",
    Declaration(decl) {
      const value = decl.value;
      if (vBindRE.test(value)) {
        vBindRE.lastIndex = 0;
        let transformed = "";
        let lastIndex = 0;
        let match;
        while ((match = vBindRE.exec(value))) {
          const start = match.index + match[0].length;
          const end = lexBinding(value, start);
          if (end !== null) {
            const variable = normalizeExpression(value.slice(start, end));
            transformed +=
              value.slice(lastIndex, match.index) +
              `var(--${genVarName(id, variable, isProd)})`;
            lastIndex = end + 1;
          }
        }
        decl.value = transformed + value.slice(lastIndex);
      }
    },
  };
};

这里的id就是我们在
doCompileStyle
函数中传过来的
shortId
,每个vue文件对应的
shortId
值都是不同的。

这里使用到了
Declaration
钩子函数,css中每个具体的样式都会触发这个
Declaration
钩子函数。


Declaration
钩子函数打个断点,当
post-css
处理到
color: v-bind(primaryColor);
时就会走到这个断点中。如下图:
before-decl

将字符串
v-bind(primaryColor)
赋值给变量
value
,接着执行
if (vBindRE.test(value))

vBindRE
是一个正则表达式,这里的意思是当前css的值是使用了v-bind指令才走到if语句里面。

接着就是执行
while ((match = vBindRE.exec(value)))
进行正则表达式匹配,如果
value
的值符合
vBindRE
正则表达式,也就是
value
的值是
v-bind
绑定的,那么就走到while循环里面去。

看到这里有的小伙伴会问了,这里使用if就可以了,为什么还要使用
while
循环呢?

答案是css的值可能是多个v-bind指令组成的,比如
border: v-bind(borderWidth) solid v-bind(primaryColor);
。这里的css值就由两个v-bind组成,分别是
v-bind(borderWidth)

v-bind(primaryColor);

为了处理上面这种多个
v-bind
指令组成的css值,所以就需要使用while循环搭配
exec
方法。正则表达式使用了global标志位的时候,js的
RegExp
对象是
有状态
的,它们会将上次成功匹配后的位置记录在
lastIndex
属性中。使用此特性,
exec()
可用来对单个字符串中的多次匹配结果进行逐条的遍历。

在debug终端来看看此时的
match
数组是什么样的,如下图:
match

从上图中可以看到
match[0]
的值是正则表达式匹配的字符串,在我们这里匹配的字符串是
v-bind(

match.index
的值为匹配到的字符位于原始字符串的基于 0 的索引值。

看到这里有的小伙伴可能对
match.index
的值有点不理解,我举个简单的例子你一下就明白了。

还是以
v-bind(borderWidth) solid v-bind(primaryColor)
为例,这个字符串就是原始字符串,第一次在while循环中正则表达式匹配到第一个bind,此时的
match.index
的值为0,也就是第一个
v
在原始字符串的位置。第二次在while循环中会基于第一次的位置接着向后找,会匹配到第二个v-bind指令,此时的
match.index
的值同样也是基于原始字符串的位置,也就是第二个
v-bind
中的
v
的位置,值为26。

在while循环中使用
const start = match.index + match[0].length

start
变量赋值,
match.index
的值是
v-bind
中的
v
的位置。
match[0]
是正则匹配到的字符串
v-bind(
。所以这个
start
的位置就是
v-bind(primaryColor)

primaryColor
变量的开始位置,也就是
p
所在的位置。

接着就是执行
lexBinding
函数拿到
v-bind(primaryColor)

primaryColor
变量的结束位置,赋值给变量
end
。在我们这个场景中简化后的
lexBinding
函数代码如下:

function lexBinding(content: string, start: number) {
  for (let i = start; i < content.length; i++) {
    const char = content.charAt(i);
    if (char === `)`) {
      return i;
    }
  }
  return null;
}

简化后的
lexBinding
函数也很简单,使用for循环遍历
v-bind(primaryColor)
字符串,如果发现字符串
)
就说明找到了
primaryColor
变量的结束位置。

接着来看拿到
end
变量后的代码,会执行
const variable = normalizeExpression(value.slice(start, end))
。这里先执行了
value.slice(start, end)
根据
start
开始位置和
end
结束位置提取出
v-bind
指令绑定的变量,接着
normalizeExpression
函数对其进行
trim
去除空格。

在我们这个场景中简化后的
normalizeExpression
函数代码如下:

function normalizeExpression(exp) {
  exp = exp.trim();
  return exp;
}

将从
v-bind
指令中提取出来的变量赋值给
variable
变量,接着执行字符串拼接拿到由
v-bind
指令转换成的css变量,代码如下:

transformed +=
  value.slice(lastIndex, match.index) +
  `var(--${genVarName(id, variable, isProd)})`;

这里的
value
是css变量值
v-bind(primaryColor)
,在我们这里
lastIndex
的值为0,
match.index
的值也是0,所以
value.slice(lastIndex, match.index)
拿到的值也是空字符串。

接着来看后面这部分,使用字符串拼接得到:
var(--变量)
。这个看着就很熟悉了,他就是一个css变量。变量名是调用
genVarName
函数生成的,
genVarName
函数代码如下:

import hash from "hash-sum";
function genVarName(id, raw, isProd) {
  if (isProd) {
    return hash(id + raw);
  } else {
    return `${id}-${getEscapedCssVarName(raw)}`;
  }
}

这个id是根据当前vue组件路径生成的,每个vue组件生成的id都不同。这个
raw
也就是绑定的响应式变量,在这里是
primaryColor

isProd
表示当前是不是生产环境。

如果是生产环境就根据id和变量名使用哈希算法生成一个加密的字符串。

如果是开发环境就使用字符串拼接将
id
和变量名
primaryColor
拼接起来得到一个css变量。
getEscapedCssVarName
函数的代码也很简单,是对变量中的特殊字符进行转义,以便在 CSS 变量名中使用。代码如下:

const cssVarNameEscapeSymbolsRE = /[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g;
function getEscapedCssVarName(key: string) {
  return key.replace(cssVarNameEscapeSymbolsRE, (s) => `\\${s}`);
}

这也就是为什么不同组件的
primaryColor
生成的css变量名称不会冲突的原因了,因为在生成的css变量前面拼接了一个
id
,每个vue组件生成的
id
值都不同。

拿到转换成css变量的css值后,并且将其赋值给变量
transformed
。接着就是执行
lastIndex = end + 1
,在我们这里
lastIndex
就指向了字符串的末尾。

最后就是执行
decl.value = transformed + value.slice(lastIndex);

v-bind
指令替换成css变量,由于
lastIndex
是指向了字符串的末尾,所以
value.slice(lastIndex)
的值也是一个空字符串。

所以在我们这里实际是执行了
decl.value = transformed
,执行完这句话后color的值就由
v-bind(primaryColor)
转换成了
var(--c845efc6-primaryColor)

生成
useCssVars
函数

前面我们讲过了编译后的setup函数中多了一个
useCssVars
函数,实际在我们的源代码中是没有这个
useCssVars
函数的。接下来我们来看看编译时处理script模块时是如何生成
useCssVars
函数的。

在之前的
为什么defineProps宏函数不需要从vue中import导入?
文章中我们讲过了vue的script模块中的代码是由
compileScript
函数处理的,当然你没看过那篇文章也不影响这篇文章的阅读。


compileScript
函数打个断点,在我们这个场景中简化后的
compileScript
函数代码如下:

function compileScript(sfc, options) {
  const ctx = new ScriptCompileContext(sfc, options);
  const startOffset = ctx.startOffset;

  ctx.s.prependLeft(
    startOffset,
    `
${genCssVarsCode(sfc.cssVars, ctx.bindingMetadata, scopeId, !!options.isProd)}
`
  );
}

首先调用
ScriptCompileContext
类new了一个
ctx
上下文对象,我们这里来介绍一下需要使用到的
ctx
上下文对象中的两个方法:
ctx.s.toString

ctx.s.prependLeft

  • ctx.s.toString
    :返回此时由script模块编译成的js代码。

  • ctx.s.prependLeft
    :给编译后的js代码在指定
    index
    的前面插入字符串。


ctx.s.prependLeft
方法打个断点,在debug终端使用
ctx.s.toString
方法来看看此时由script模块编译成的js代码是什么样的,如下图:
before-append

从上图中可以看到此时生成的js代码code字符串只有一条
import
语句和定义
primaryColor
变量。

由于篇幅有限我们就不深入到
genCssVarsCode
函数了,这个
genCssVarsCode
函数会生成
useCssVars
函数的调用。我们在debug终端来看看生成的code代码字符串是什么样的,如下图:
genCssVarsCode

从上图中可以看到
genCssVarsCode
函数生成了一个
useCssVars
函数。

执行
ctx.s.prependLeft
函数后会将生成的
useCssVars
函数插入到生成的js code代码字符串的前面,我们在debug终端来看看,如下图:
after-append

从上图中可以看到此时的js code代码字符串中已经有了一个
useCssVars
函数了。

执行
useCssVars
函数

前面我们讲过了编译时经过
cssVarsPlugin
这个
post-css
插件处理后,
v-bind(primaryColor)
指令就会编译成了css变量
var(--c845efc6-primaryColor)
。这里只是使用css变量值的地方,那么这个css变量的值又是在哪里定义的呢?答案是在
useCssVars
函数中。

在开始我们讲过了编译后的setup函数中多了一个
useCssVars
函数,所以我们给
useCssVars
函数打个断点,刷新浏览器此时代码就会走到断点中了。如下图:
useCssVars

从上图中可以看到执行
useCssVars
函数时传入了一个回调函数作为参数,这个回调函数返回了一个对象。

将断点走进
useCssVars
函数,在我们这个场景中简化后的
useCssVars
函数代码如下:

function useCssVars(getter) {
  const instance = getCurrentInstance();

  const setVars = () => {
    const vars = getter(instance.proxy);
    setVarsOnVNode(instance.subTree, vars);
  };

  watchPostEffect(setVars);
}


useCssVars
函数中先调用
getCurrentInstance
函数拿到当前的vue实例,然后将
setVars
函数作为参数传入去执行
watchPostEffect
函数。

这个
watchPostEffect
函数大家应该知道,他是
watchEffect()
使用
flush: 'post'
选项时的别名。

为什么需要使用
flush: 'post'
呢?

答案是需要在
setVars
回调函数中需要去操作DOM,所以才需要使用
flush: 'post'
让回调函数在组件渲染完成之后去执行。


setVars
函数打个断点,组件渲染完成后断点将会走进
setVars
函数中。

首先会执行
getter
函数,将返回值赋值给变量
vars
。前面我们讲过了这个
getter
函数是调用
useCssVars
函数时传入的回调函数,代码如下:

_useCssVars((_ctx) => ({
  "c845efc6-primaryColor": primaryColor.value
}))

在这个回调函数中会返回一个对象,对象的key为
c845efc6-primaryColor
,这个key就是css变量
var(--c845efc6-primaryColor)
括号中的内容。

对象的值是ref变量
primaryColor
的值,由于这个代码是在
watchPostEffect
的回调函数中执行的,所以这里的ref变量
primaryColor
也被作为依赖进行收集了。当
primaryColor
变量的值变化时,
setVars
函数也将再次执行。这也就是为什么在style中可以使用v-bind指令绑定一个响应式变量,并且当响应式变量的值变化时样式也会同步更新。

接着就是执行
setVarsOnVNode(instance.subTree, vars)
函数,传入的第一个参数为
instance.subTree
。他的值是当前vue组件根元素的虚拟DOM,也就是根元素div的虚拟DOM。第二个参数为
useCssVars
传入的回调函数返回的对象,这是一个css变量组成的对象。

接着将断点走进
setVarsOnVNode
函数,在我们这个场景中简化后的代码如下:

function setVarsOnVNode(vnode: VNode, vars) {
  setVarsOnNode(vnode.el, vars);
}


setVarsOnVNode
函数中是调用了
setVarsOnNode
函数,不同的是传入的第一个参数不再是虚拟DOM。而是
vnode.el
虚拟DOM对应的真实DOM,也就是根节点div。

将断点走进
setVarsOnNode
函数,在我们这个场景中简化后的
setVarsOnNode
函数代码如下:

function setVarsOnNode(el: Node, vars) {
  if (el.nodeType === 1) {
    const style = el.style;
    for (const key in vars) {
      style.setProperty(`--${key}`, vars[key]);
    }
  }
}


setVarsOnNode
函数中先使用if语句判断
el.nodeType === 1
,这个的意思是判断当前节点类型是不是一个元素节点,比如
<p>

<div>
。如果是就走进if语句里面,使用
el.style
拿到根节点的style样式。

这里的
vars
是css变量组成的对象,遍历这个对象。对象的key为css变量名称,对象的value为css变量的值。

接着就是遍历css变量组成的对象,使用
style.setProperty
方法给根节点div增加内联样式,也就是
--c845efc6-primaryColor: red;

span
元素由于是根节点div的子节点,所以他也继承了样式
--c845efc6-primaryColor: red;

由于span元素的color经过编译后已经变成了css变量
var(--c845efc6-primaryColor)
,并且从根节点继承过来css变量
--c845efc6-primaryColor
的值为
red
,所以最终span元素的color值为
red

总结

下面这个是我总结的流程图,如下(
搭配流程图后面的文字解释一起服用效果最佳
):
full-progress

编译阶段script模块是由
compileScript
函数处理的,
compileScript
函数会去执行一个
genCssVarsCode
函数。这个函数会返回一个
useCssVars
函数的调用。然后在
compileScript
函数中会调用
ctx.s.prependLeft
方法将生成的
useCssVars
函数插入到编译后的setup函数中。

编译阶段style模块是由
doCompileStyle
函数处理的,在
doCompileStyle
函数中会调用
postcss
对css样式进行处理。vue自定义了一个名为
cssVarsPlugin

postcss
插件,插件中有个
Declaration
钩子函数,css中每个具体的样式都会触发这个
Declaration
钩子函数。


Declaration
钩子函数中使用正则表达式去匹配当前css值是不是
v-bind
绑定的,如果是就将匹配到的
v-bind
绑定的变量提取出来赋值给变量
variable
。还有一个
id
变量,他是根据当前vue组件的路径生成的加密字符串。使用字符串拼接就可以得到
var(--${id}-${variable})
,他就是由
v-bind
编译后生成的css变量。最终生成的css变量类似这样:
var(--c845efc6-primaryColor)

运行时阶段初始化的时候会去执行setup函数,由于在编译阶段setup函数中插入了一个
useCssVars
函数。使用在运行时阶段初始化时
useCssVars
函数会被执行。


useCssVars
函数中执行了
watchPostEffect
函数,他是
watchEffect()
使用
flush: 'post'
选项时的别名。

由于我们需要在回调中操作DOM,所以才需要使用
flush: 'post'
,让回调函数在组件渲染之后去执行。由于在回调函数中会去读取
v-bind
绑定的响应式变量,所以每次绑定的响应式变量值变化后都会再次执行调用
watchPostEffect
传入的回调函数,以此让响应式变量绑定的样式保存更新。


watchPostEffect
传入的回调函数中会通过当前vue组件实例拿到真实DOM的根节点,然后遍历css变量组成的对象,将这些css变量逐个在根节点上面定义,类似这样:
--c845efc6-primaryColor: red;
。由于css可以继承,所以子节点都继承了这个css定义。

我们的
<span>
标签在编译阶段由
color: v-bind(primaryColor);
编译成了css变量
color: var(--c845efc6-primaryColor)
。并且在运行时由于
useCssVars
函数的作用在根节点生成了css变量的定义
--c845efc6-primaryColor: red;
。由于css继承,所以span标签也继承了这个css变量的定义,所以span标签渲染到页面上的color值最终为
red

关注公众号:【前端欧阳】,给自己一个进阶vue的机会