2024年3月

先来一段紧箍咒:nvm、fvm、gvm、sdkman、fnm、n、g、rvm、jenv、phpbrew、rustup、swiftenv、pyenv、rbenv...

这些都是用来解决编程语言多版本管理的工具,如果你是个程序员肯定认识或是用过几个,但是刚接触编程的小白,就会有些挠头了。

啥是编程语言版本管理工具?它们有什么用呢?

举个例子,用 Java 的开发者可能会遇见的问题,公司的项目是万年不变 JDK 8,但个人项目用的是最新的 JDK 21。这种情况下,在一台电脑上开发公司和个人项目的时候,就需要切换一下当前开发环境对应的 JDK 版本,否则项目跑不起来。编程语言版本管理工具就是用来
切换/管理编程语言不同版本的工具
,比如 Java 语言对应的工具是
jenv

每一种编程语言都有一个对应的版本管理工具,
对于多语言开发者来说就需要安装、配置、学习各种版本管理工具,记忆不同工具的使用命令
,这和紧箍咒无异。那咋办啊?

莫慌,今天 HelloGitHub 带来的是一款跨平台版本、支持多语言的版本管理工具——vfox,让你无忧应对多编程语言、不同版本的开发环境。该项目由国人(99 年的小伙)开发,更贴合国内开发者的使用习惯。

GitHub 地址:
https://github.com/version-fox/vfox

接下来,让我们一起走近 vfox 了解它的功能、上手使用、技术原理和强大的插件系统吧!

一、介绍

vfox 是一个类 nvm、fvm、sdkman、asdf 的版本管理工具,具有
跨平台

通用

易拓展
的特性:

  • 简单:安装简单,一套命令管理所有语言
  • 跨平台:支持
    Windows
    、Linux、macOS
  • 人性化:换项目时
    自动切换到对应编程语言
    、支持自动补全
  • 扩展性:容易上手的插件系统,添加冷门的编程语言
  • 作用域:支持 Global、Project、Session 三种作用域

质疑声:同类型的项目挺多的啊,不能一个国人开发、开源就来求 Star 吧?

下面,我们就来和在 GitHub 上有 20k Star 的同类型工具 asdf PK 一下,看看 vfox 是不是重复造轮子,到底能不能打!

二、对比 asdf

这里主要从
操作系统兼容性、性能和插件换源
三个方面进行对比。

2.1 兼容性

兼容性 Windows Linux macOS
asdf
vfox

首先,asdf 是用 shell 脚本实现的工具,所以并
不支持原生 Windows 环境
。而 vfox 是用 Go + Lua 实现的,因此天生支持 Windows 和其他操作系统。

2.2 性能

上图是对两个工具最核心的切换版本功能进行基准测试的结果,很容易就能得出结论:vfox 比 asdf
快 5 倍

速度 平均 最快 最慢
asdf 158.7 ms 154 ms 168.4 ms
vfox 28.1ms 27.1 ms 32.3 ms

技术解析
:asdf 执行切换版本的速度之所以较慢,主要是由于其
垫片机制
。简单来说,当你尝试运行如 node 这样的命令时,asdf 会首先查找对应的垫片,然后根据
.tool-versions
文件或全局设置来确定使用哪个版本的 node 。这个查找和确定版本的过程会消耗一定的时间,从而影响了命令的执行速度。

相比之下,vfox 则采用了直接操作环境变量的方式来管理版本,它会直接设置和切换环境变量,从而避免了查找和确定版本的过程。因此,在执行速度上要比使用垫片机制的 asdf 快得多。

虽然 asdf 很强,但是它对 Windows 原生无能为力。虽然 vfox 很新,但在
性能和跨平台方面做得更好

2.3 插件换源

大多数时候,我们会被网络问题而困扰,所以切换下载源的操作是必不可少的。

下面以切换 Node.js 源为例,对比 asdf 和 vfox 在换源时的区别。

asdf 是通过
asdf-vm/asdf-nodejs
插件实现了对于 Node.js 的支持,但该插件是需要手动预定义一个环境变量来修改下载源,多语言换源还需要设置多个不同的环境变量。

  • 优点:可以灵活切换任何镜像源
  • 缺点:需要手动设置,操作不友好

vfox 选择了另一种方法,即一个镜像源对应一个插件。

$ vfox add nodejs/nodejs # 使用官方下载源
$ vfox add nodejs/npmmirror # 使用 npmmirror 镜像

$ vfox add python/python # 官方下载源
$ vfox add python/npmmirror

虽然这样会使仓库的插件变多,但使用起来降低了负担,也
没有乱七八糟的环境变量需要配置
,对用户非常友好!

三、上手

说了这么多,还没上手玩一下简直忍不了。

3.1. 安装

Windows
用户只需要下载安装器进行安装即可,
Linux
用户可以使用 APT 或 YUM 来快速安装,
macOS
用户可以使用 Homebrew 安装。更详细的安装方式可
查看文档

$ brew tap version-fox/tap
$ brew install vfox

安装完成之后,需要将 vfox 挂载到你的 shell 中,从下面条目中选择一条适合你 shell 的。

echo 'eval "$(vfox activate bash)"' >> ~/.bashrc
echo 'eval "$(vfox activate zsh)"' >> ~/.zshrc
echo 'vfox activate fish | source' >> ~/.config/fish/config.fish

# 对于 Powershell 用户,将下面行添加到你的 $PROFILE 文件中
Invoke-Expression "$(vfox activate pwsh)"

3.2 使用

安装好了,但你还做不了任何事情,因为 vfox 是使用插件作为扩展,按需安装。

不知道应该添加哪些插件,可以用
vfox available
命令查看所有可用插件

所以你还需要安装插件,以 Node.js 为例,为了获得更好的体验,我们添加 npmmirror 镜像源插件:
vfox add nodejs/npmmirror

在插件成功安装之后,你就可以玩起来了!

  • 安装指定版本:
    vfox install nodejs@<version>
  • 安装最新版本:
    vfox install nodejs@latest
  • 切换版本:
    vfox use nodejs[@<version>]

文字表达远不如图片来的更直观,我们直接上效果图。

四、技术原理

vfox 支持 Global、Session、Project 三种作用域,这三种作用域能够满足我们日常开发所需的场景。

作用域 命令 说明
Global vfox use -g <sdk-name> 全局范围有效
Session vfox use -s <sdk-name> 当前 shell 会话有效
Project vfox use -p <sdk-name> 当前项目下有效

那么你对它们的实现原理感兴趣吗?咱们废话不多说,直接看原理图!

vfox 是基于 shell 的 hook 机制实现的,hook 机制简单来说就是每当我们执行完命令之后,shell 都会调用一下你配置的钩子函数(hook),即
vfox env <shell-name>
命令,我们后面解释这个命令是干什么的。

说回到作用域上来,vofox 是通过
.tool-versions
文件来记录每个 SDK 对应的版本号信息。对于三种作用域,会分别在不同的地方创建
.tool-versions
文件,用于记录作用域内所需要的 SDK 版本信息。

  • Global
    ->
    $HOME/.version-fox/.tool-versions
  • Project
    ->
    当前项目目录
  • Session
    ->
    $HOME/.version-fox/tmp/<shell-pid>/.tool-versions

代码如下:

func newSdkManagerWithSource(sources ...RecordSource) *Manager {
    meta, err := newPathMeta()
    if err != nil {
       panic("Init path meta error")
    }
    var paths []string
    for _, source := range sources {
        // 根据不同的作用域选择性加载不同位置的.tool-versions文件
       switch source {
       case GlobalRecordSource:
          paths = append(paths, meta.ConfigPath)
       case ProjectRecordSource:
           // 当前目录
          curDir, err := os.Getwd()
          if err != nil {
             panic("Get current dir error")
          }
          paths = append(paths, curDir)
       case SessionRecordSource:
           // Shell会话临时目录
          paths = append(paths, meta.CurTmpPath)
       }
    }
    // env.Record是用来专门操作.tool-versions文件的, 增删改查
    var record env.Record
    if len(paths) == 0 {
       record = env.EmptyRecord
    } else if len(paths) == 1 {
       r, err := env.NewRecord(paths[0])
       if err != nil {
          panic(err)
       }
       record = r
    } else {
       r, err := env.NewRecord(paths[0], paths[1:]...)
       if err != nil {
          panic(err)
       }
       record = r
    }
    // SdkManager是用来专门管理Sdk的组件, 到这里Manager就可以通过Record来获取和修改Sdk版本信息咯
    return newSdkManager(record, meta)
}

上面提到,最核心的其实是 hook 机制调用的
vfox env <shell-name>
命令,那它到底干了件什么事情呢?

func envCmd(ctx *cli.Context) error {
    ...
        // 拿到对应shell的组件
       s := shell.NewShell(shellName)
       if s == nil {
          return fmt.Errorf("unknow target shell %s", shellName)
       }
       // 上面提到的加载.tool-versions信息到Manager中
       manager := internal.NewSdkManagerWithSource(internal.SessionRecordSource, internal.ProjectRecordSource)
       defer manager.Close()
       // 获取需要配置的环境变量信息
       envKeys, err := manager.EnvKeys()
       if err != nil {
          return err
       }
       // 将环境变量信息, 翻译成符合对应shell的命令
       exportStr := s.Export(envKeys)
       fmt.Println(exportStr)
       return nil
    }
}

func (m *Manager) EnvKeys() (env.Envs, error) {
    shellEnvs := make(env.Envs)
    var paths []string
    // 这里就是前面说的, Record包含了所有的版本信息, 只需要取出来即可
    for k, v := range m.Record.Export() {
       if lookupSdk, err := m.LookupSdk(k); err == nil {
          if keys, err := lookupSdk.EnvKeys(Version(v)); err == nil {
             for key, value := range keys {
                if key == "PATH" {
                   paths = append(paths, *value)
                } else {
                   shellEnvs[key] = value
                }
             }
          }
       }
    }
   ...
    return shellEnvs, nil
}

没看懂代码没关系,用一句话概括这段代码的功能:

.tool-versions
记录的 SDK 版本信息,翻译成具体 shell 可执行的命令

,其实核心技术就这么朴实无华。

五、插件系统

插件系统是 vfox 的核心,它赋予 vfox 无限的可能性,不仅仅局限于单一的 SDK。通过插件系统,vfox 能够灵活地适应任何 SDK 的需求,无论是现有的还是未来可能出现的。

更重要的是,插件系统使用 Lua 作为插件的开发语言,内置了一些常用模块,如
http

json

html

file
等,这使得插件系统不仅功能强大,而且
易于开发和自定义
。用户可以根据自己的需求,轻松编写和定制自己的脚本,从而实现更多的功能。

口说无凭,我们直接写一个简单的插件来体验一下,以写一个
Windows 环境下可用的 Python 插件
为例。

5.1 插件模板结构

在开工之前,我们首先需要了解一下插件结构是什么样子,以及都提供了哪些钩子函数供我们实现。

--- 内置全局变量: 操作系统和架构类型
OS_TYPE = ""
ARCH_TYPE = ""
--- 描述当前插件的基本信息, 插件名称、版本、最低运行时版本等信息
PLUGIN = {
    name = "xxx",
    author = "xxx",
    version = "0.0.1",
    description = "xxx",
    updateUrl = "https://localhost/xxx.lua",
    minRuntimeVersion = "0.2.3",
}
--- 1.预安装钩子函数。vfox 会根据提供的元信息, 帮你提前下载好所需的文件(如果是压缩包,会帮你解压)放到指定目录。
function PLUGIN:PreInstall(ctx)
    return {
      version = "0.1.1",
      sha256 = "xxx", --- 可选
      sha1 = "xxx", --- 可选
      url = "文件地址"
    }
end
--- 2.后置钩子函数。这里主要是做一些额外操作, 例如编译源码。
function PLUGIN:PostInstall(ctx)
end
--- 3.可用钩子函数。 告诉 vfox 当前插件都有哪些可用版本。
function PLUGIN:Available(ctx) 
end
--- 4.环境信息钩子函数。 告诉 vfox 当前SDK所需要配置的环境变量有哪些。
function PLUGIN:EnvKeys(ctx)
end

总共就 4 个钩子函数,是不是非常简单。

5.2 Python 插件实现

OK,万事俱备那我们正式开始实现 Python 插件咯~

--- vfox 提供的库
local http = require("http") --- 发起 http 请求
local html = require("html") --- 解析 html
OS_TYPE = ""
ARCH_TYPE = ""

--- python 下载源地址信息
local PYTHON_URL = "https://www.python.org/ftp/python/"
local DOWNLOAD_SOURCE = {
    --- ...
    EXE = "https://www.python.org/ftp/python/%s/python-%s%s.exe",
    SOURCE = "https://www.python.org/ftp/python/%s/Python-%s.tar.xz"
}

PLUGIN = {
    name = "python",
    author = "aooohan",
    version = "0.0.1",
    minRuntimeVersion = "0.2.3", 
}

function PLUGIN:PreInstall(ctx)
    --- 拿到用户输入版本号, 解析成具体版本号
    local version = ctx.version
    if version == "latest" then
        version = self:Available({})[1].version
    end
    if OS_TYPE == "windows" then
        local url, filename = checkAvailableReleaseForWindows(version)
        return {
            version = version,
            url = url,
            note = filename
        }
    else
        --- 非 Windows 环境实现, 略
    end
end

function checkAvailableReleaseForWindows(version)
    --- 处理架构类型, 同一架构的不同名称
    local archType = ARCH_TYPE
    if ARCH_TYPE == "386" then
        archType = ""
    else
        archType = "-" .. archType
    end
    --- 检查是否存在 exe 安装器, 当然 Python 还提供了其他安装器, 例如 msi、web-installer 等
    local url = DOWNLOAD_SOURCE.EXE:format(version, version, archType)
    local resp, err = http.head({
        url = url
    })
    if err ~= nil or resp.status_code ~= 200 then
        error("No available installer found for current version")
    end
    return url, "python-" .. version .. archType .. ".exe"
end


--- vfox 会在 PreInstall 执行完之后, 执行当前钩子函数.
function PLUGIN:PostInstall(ctx)
    if OS_TYPE == "windows" then
        return windowsCompile(ctx)
    else
        --- 略
    end
end

function windowsCompile(ctx)
    local sdkInfo = ctx.sdkInfo['python']
    --- vfox 分配的安装路径
    local path = sdkInfo.path
    local filename = sdkInfo.note
    --- exe 安装器路径
    local qInstallFile = path .. "\\" .. filename
    local qInstallPath = path
    --- 执行安装器
    local exitCode = os.execute(qInstallFile .. ' /quiet InstallAllUsers=0 PrependPath=0 TargetDir=' .. qInstallPath)
    if exitCode ~= 0 then
        error("error installing python")
    end
    --- 清理安装器
    os.remove(qInstallFile)
end

--- 告诉 vfox 可用版本
function PLUGIN:Available(ctx)
    return parseVersion()
end

function parseVersion()
    --- 这里就是解析对应的 html 页面, 通过正则匹配具体版本号了
    local resp, err = http.get({
        url = PYTHON_URL
    })
    if err ~= nil or resp.status_code ~= 200 then
        error("paring release info failed." .. err)
    end
    local result = {}
    --- 解析 html 略 
    return result
end

--- 配置环境变量, 主要是 PATH, 但是注意 Windows 和 Unix-like 路径不一致, 所以要区分
function PLUGIN:EnvKeys(ctx)
    local mainPath = ctx.path
    if OS_TYPE == "windows" then
        return {
            {
                key = "PATH",
                value = mainPath
            }
        }
    else
        return {
            {
                key = "PATH",
                value = mainPath .. "/bin"
            }
        }
    end
end


至此,我们就完成了一个 Windows 环境下可用的 Python 插件啦~

前言

T检验是一种用于比较两个独立样本均值差异的统计方法。它通过计算T值和P值来判断样本之间是否存在显著性差异。通常情况下,我们会有两组数据,例如一组实验组和一组对照组。

T检验的原假设是两组样本的均值相等,备假设是两组样本的均值不相等。T检验会计算一个T值,表示两组样本均值之间的差异。同时,还会计算一个P值,用来判断这个差异是否显著。

如果P值小于显著性水平(通常设定为0.05),我们就可以拒绝原假设,认为两组样本的均值存在显著差异。反之,如果P值大于显著性水平,我们接受原假设,认为两组样本的均值没有显著差异。

T检验有不同的类型,最常见的是独立样本T检验和配对T检验。独立样本T检验用于比较两组独立样本的均值差异,而配对T检验用于比较同一组样本在不同条件下的均值差异。

我们将继续采用Python编程语言进行实现,这次我们会利用到scipy库。scipy库是一个基于Python的开源科学计算库,它构建在NumPy库的基础之上,扩展了更多数学函数和算法,涵盖了优化、插值、统计、信号处理、图像处理、常微分方程求解等广泛的功能。

接下来,让我们对这两种场景进行简要讨论,以便更好地理解它们的特点和应用条件。

独立样本

我们先来看下独立双样本,举个例子:假设我们有两组学生,一组接受了数学辅导班,另一组没有接受辅导。我们想要比较两组学生在数学考试成绩上是否有显著差异。

案例背景:

  • 组1(辅导班):
    10名学生,他们的数学考试成绩为 [85, 88, 90, 92, 95, 78, 80, 84, 88, 86]。
  • 组2(非辅导班):
    10名学生,他们的数学考试成绩为 [75, 78, 80, 82, 85, 68, 70, 74, 78, 76]。

我们将实现一个简单独立样本T检验来比较这两组学生的平均数学考试成绩是否有显著差异。

Python代码实现:

import scipy.stats as stats

# 组1(辅导班)的数学考试成绩
group1_scores = [85, 88, 90, 92, 95, 78, 80, 84, 88, 86]

# 组2(非辅导班)的数学考试成绩
group2_scores = [75, 78, 80, 82, 85, 68, 70, 74, 78, 76]

# 执行独立样本T检验
t_statistic, p_value = stats.ttest_ind(group1_scores, group2_scores)

# 输出T值和P值
print("T值:", t_statistic)
print("P值:", p_value)

# 判断显著性水平
alpha = 0.05
if p_value < alpha:
    print("在显著性水平为0.05下,拒绝原假设,即两组学生的数学考试成绩存在显著差异。")
else:
    print("在显著性水平为0.05下,接受原假设,即两组学生的数学考试成绩没有显著差异。")

这里将计算组1(接受辅导班)和组2(未接受辅导班)学生的数学考试成绩的独立样本T检验,输出T值和P值,并根据显著性水平0.05判断是否拒绝原假设。

运行结果:在显著性水平为0.05下,拒绝原假设,即两组学生的数学考试成绩存在显著差异

看来补习辅导班还是有道理的,孩子苦啊~~

配对T检验

接下来,让我们继续探讨配对T检验的情况。在这种情况下,我们需要关注的是样本数据并没有发生变化,即我们在比较的是同一个样本在不同条件下的表现。假设我们有一组学生在学习前和学习后的数学成绩数据,我们想要确定他们的成绩在学习前后是否有显著差异。这时可以使用配对T检验进行分析。

在这个案例中,首先,我们需要创建示例数据,接下来,我们将使用
stats.ttest_rel
函数执行配对T检验,并输出结果:

import numpy as np
from scipy import stats

# 创建示例数据,学习前和学习后的成绩
before_scores = np.array([70, 75, 80, 65, 72])
after_scores = np.array([75, 80, 85, 70, 78])

# 执行配对T检验
t_statistic, p_value = stats.ttest_rel(before_scores, after_scores)

# 输出T统计量和P值
print("T统计量:", t_statistic)
print("P值:", p_value)

# 判断显著性水平
if p_value < 0.05:
    print("学习前后成绩存在显著差异")
else:
    print("学习前后成绩没有显著差异")

运行以上代码,我们可以得到配对T检验的结果,包括T统计量和P值。根据P值与显著性水平的比较,我们可以判断学习前后成绩是否存在显著差异。

运行结果:学习前后成绩存在显著差异

经过一番讨论,我们一直在对P值进行验证,那么这与T检验有什么关联呢?让我们探究一下它们之间的联系。

我们通过计算T值来判断两组样本均值是否有显著差异。如果计算得到的T值较大,意味着两组样本的均值差异较大,反之则差异较小。一般来说,T值大于1.96或小于-1.96时,即绝对值大于1.96时,我们可以认为两组样本均值之间存在显著差异,P值也会小于0.05。因此,T值的大小也会帮助我们判断两组样本均值之间的差异是否具有统计学意义。

总结

独立样本T检验适用于比较两组独立样本的均值差异,而配对T检验则适用于比较同一组样本在不同条件下的均值差异。在Python中,我们可以利用scipy库进行T检验的实现和结果判断。通过比较P值与显著性水平,我们可以判断两组样本均值是否存在显著差异。T值的大小也对判断两组样本均值差异的统计学意义起着重要作用。

引言

在现代软件开发中,定时任务是一种常见的需求,用于执行周期性的任务或在特定的时间点执行任务。这些任务可能涉及数据同步、数据备份、报表生成、缓存刷新等方面,对系统的稳定性和可靠性有着重要的影响。
Spring Boot
提供了强大且简单的定时任务功能,使开发人员能够轻松地管理和执行这些任务。

本文将介绍
Spring Boot
中定时任务的基本用法、高级特性以及最佳实践,帮助开发人员更好地理解和应用定时任务,提高系统的稳定性和可靠性。

SpringBoot中的定时任务

SpringBoot
中的定时任务主要通过
@Scheduled
注解以及
SchedulingConfigurer
接口实现。

@Scheduled注解

@Scheduled
注解是
Spring
提供的一个注解,用于标记方法作为定时任务执行。通过
@Scheduled
注解,开发人员可以轻松地配置方法在指定的时间间隔或时间点执行,实现各种定时任务需求。

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
@Repeatable(Schedules.class)  
public @interface Scheduled {

	String cron() default "";

	long fixedDelay() default -1;

	long fixedRate() default -1;

	long initialDelay() default -1;
}

以上为
@Scheduled
源码中关键属性,各属性含义如下:

  • cron: 接受标准的Unix Cron表达式,用于定义复杂的计划执行时间。
/**  
* cron属性可以设置指定时间执行,cron表达式跟linux一样  
*/  
@Scheduled(cron = "0 45 14 ? * *")  
public void fixTimeExecution() {  
	System.out.println("指定时间 "+dateFormat.format(new Date())+"执行");  
}
  • fixedRate: 以固定的频率执行任务,指定两次执行之间的间隔时间(单位是毫秒)。
/**  
* fixedRate属性设置每隔固定时间执行  
*/  
@Scheduled(fixedRate = 5000)  
public void reportCurrentTime() {  
	System.out.println("每隔五秒执行一次" + dateFormat.format(new Date())); 
}
  • fixedDelay:在每次任务完成后等待一定的时间再进行下一次执行,指定连续执行之间的延迟时间。
/**  
* 上一次任务执行完成之后10秒后在执行  
*/  
@Scheduled(fixedDelay = 10000)  
public void runWithFixedDelay() {  
	System.out.println("指定时间 "+dateFormat.format(new Date())+"执行");  
}
  • initialDelay:首次执行前的延迟时间。
/**  
* 初始延迟1秒后开始,然后每10秒执行一次  
*/  
@Scheduled(initialDelay=1000, fixedDelay=10000)  
public void executeWithInitialAndFixedDelay() {  
	System.out.println("指定时间 "+dateFormat.format(new Date())+"执行");  
}

这里要注意fixedRate与fixedDelay的区别:
fixedRate
是基于任务开始执行的时间点来计算下一次任务开始执行的时间,因此任务的执行时间间隔是相对固定的,不受到任务执行时间的影响。如果指定的时间间隔小于任务执行的实际时间,则任务可能会并发执行。而
fixedDelay
是基于任务执行完成的时间点来计算下一次任务开始执行的时间,因此任务的执行时间间隔是相对不规则的,受到任务执行时间的影响。

SpringBoot
支持同时定义多个定时任务方法,每个方法可以使用不同的参数配置,以满足不同的定时任务需求。同时,我们必须在配置类中使用
@EnableScheduling
注解开启定时任务。

@Configuration  
@EnableScheduling  
public class ScheduledTaskConfig { 

}

或者

@EnableScheduling  
@SpringBootApplication  
public class SpringBootBaseApplication {  
  
    public static void main(String[] args) {  
       SpringApplication.run(SpringBootBaseApplication.class, args);  
    }  
}

在SpringBoot应用程序中,除了在代码中使用注解配置定时任务外,还可以通过配置文件来配置定时任务的执行规则。这种方式更加灵活,可以在不修改源代码的情况下,动态调整定时任务的执行规则。比如我们在
application.properties
中配置
@Scheduled
的属性:

custom.scheduled.cron = 0/5 * * * * ?  
custom.scheduled.fixedRate=5000  
custom.scheduled.fixedDelay=10000  
custom.scheduled.initialDelay=1000

然后在
@Scheduled
的方法使用属性配置定时任务执行频率。

@Service  
public class DemoScheduledTaskService {  
  
    private static final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");  
  
    /**  
     * fixedRate属性设置每隔固定时间执行  
     */  
    @Scheduled(fixedRateString = "${custom.scheduled.fixedRate}")  
    public void reportCurrentTime() {  
        System.out.println("每隔五秒执行一次" + dateFormat.format(new Date()));  
    }  
  
    /**  
     * cron属性可以设置指定时间执行,cron表达式跟linux一样  
     */  
    @Scheduled(cron = "${custom.scheduled.cron}")  
    public void fixTimeExecution() {  
        System.out.println("指定时间 "+dateFormat.format(new Date())+"执行");  
    }  
  
    /**  
     * 上一次任务执行完成之后10秒后在执行  
     */  
    @Scheduled(fixedDelayString = "${custom.scheduled.fixedDelay}")  
    public void runWithFixedDelay() {  
        System.out.println("指定时间 "+dateFormat.format(new Date())+"执行");  
    }  
  
    /**  
     * 初始延迟1秒后开始,然后每10秒执行一次  
     */  
    @Scheduled(initialDelayString = "${custom.scheduled.initialDelay}", fixedDelayString = "${custom.scheduled.fixedDelay}")  
    public void executeWithInitialAndFixedDelay() {  
        System.out.println("指定时间 "+dateFormat.format(new Date())+"执行");  
    }  
}

注意,这里使用属性来指定任务执行频率时,要通过@Scheduled的fixedRateString、fixedDelayString、initialDelayString三个可以指定字符串的值的属性去指定,效果等同于long类型的属性。

通过配置文件配置定时任务具有很高的灵活性,可以在不重新编译和部署应用程序的情况下,随时调整定时任务的执行规则。同时,也可以根据不同的环境(例如开发、测试、生产)配置不同的定时任务规则,以满足不同环境下的需求。这种方式可以有效地解耦定时任务的配置和业务代码,提高系统的灵活性和可维护性。

另外,如果希望定时任务能够异步执行,不阻塞主线程,可以在方法上同时加上
@Async
注解,这样各任务就可以异步执行了。有关SpringBoot中使用
@Async
的讲解,请移步:

虽然
@Scheduled
注解是一个方便的方式来定义定时任务,但它也存在一些弊端。因为任务的执行计划(如cron表达式)在编译时被硬编码,因此无法在运行时动态修改,除非重新部署。此外,@Scheduled注解对于配置不同的调度策略(如使用不同的线程池)显得力不从心,而且默认情况下,@Scheduled任务在单线程环境下执行,可能出现任务堆积的情况,尤其在任务量大或任务执行时间长的情况下,而且这些任务可能会变得混乱和难以管理。定时任务的配置分散在各个任务方法中,不利于统一管理和维护。对于需要根据动态条件创建或销毁定时任务的情况,
@Scheduled
注解也无法满足需求。

为了解决这些问题,可以使用
SchedulingConfigurer
接口来动态地创建和管理定时任务。通过实现
SchedulingConfigurer
接口,我们可以编写代码来动态地注册和管理定时任务,从而实现灵活的任务调度需求。接下来,我们将介绍如何使用
SchedulingConfigurer
接口来创建定时任务。

SchedulingConfigurer接口

SchedulingConfigurer
接口是 Spring 提供的一个用于定时任务配置的扩展接口,它允许开发人员更细粒度地控制定时任务的执行。通过实现
SchedulingConfigurer
接口,可以自定义任务调度器(
TaskScheduler
),配置线程池等参数,以满足不同场景下的定时任务需求。

@Configuration  
@EnableScheduling  
public class CustomSchedulingConfig implements SchedulingConfigurer {

	@Override  
	public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
		// 定时任务逻辑
	}
}

通过实现
SchedulingConfigurer
接口,重写
configureTasks
方法,自定义任务调度器的配置。此外我们还可以配置线程池,用于控制定时任务执行时的线程数量、并发性等参数。

@Bean(destroyMethod = "shutdown")  
public ThreadPoolTaskScheduler threadPoolTaskScheduler() {  
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();  
    scheduler.setPoolSize(5); // 设置线程池大小  
    scheduler.setThreadNamePrefix("scheduled-task-"); // 设置线程名称前缀  
    scheduler.setAwaitTerminationSeconds(60); // 设置终止等待时间  
	// 设置处理拒绝执行的任务异常
	scheduler.setRejectedExecutionHandler((r, executor) -> log.error("Task rejected", r));
	// 处理定时任务执行过程中抛出的未捕获异常
	scheduler.setErrorHandler(e -> log.error("Error in scheduled task", e));
    return scheduler;  
}

然后将自定义的
ThreadPoolTaskScheduler
设置到
ScheduledTaskRegistrar
中去:

@Override  
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
	// 定时任务逻辑
	taskRegistrar.setTaskScheduler(threadPoolTaskScheduler());
}

有关线程池的配置参数讲解,请移步:

通过
SchedulingConfigurer
接口,可以更灵活地配置任务调度器和定时任务的执行规则,比如动态注册定时任务、动态修改任务执行规则等。

  • 动态添加定时任务

    SchedulingConfigurer

    configureTasks
    方法中,我们可以根据业务需求,从数据库、配置文件或其它动态来源获取定时任务的信息(如Cron表达式、任务执行类等),然后创建对应的
    Runnable

    Callable
    实例,并结合
    Trigger
    (如
    CronTrigger
    )将其添加到调度器中。相比
    @Scheduled
    注解,这种方式能够在应用运行时随时添加新的定时任务。
@Override  
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {  
    ThreadPoolTaskScheduler scheduler = threadPoolTaskScheduler();  
    taskRegistrar.setTaskScheduler(scheduler);  
  
    List<CronTaskInfo> tasksFromDB = listTasksFromDatabase();  
  
    for (CronTaskInfo task : tasksFromDB) {  
        Runnable taskRunner = new MyTaskExecutor(task.getTaskData());  
        CronTrigger cronTrigger = new CronTrigger(task.getCronExpression());  
        scheduler.schedule(taskRunner, cronTrigger);  
    }  
}

关于这里在应用运行时,动态的添加新的任务,我们可以通过事件驱动,轮训检查,消息队列等多种方式,监听到数据库中或者配置文件中新增任务信息,然后通过
SchedulingConfigurer
接口动态创建定时任务。而这种方式是@Scheduled注解做不到的。

  • 修改定时任务规则
    当任务的执行规则需要动态变更时,同样可以在
    configureTasks
    方法中实现。例如,从数据库获取最新的Cron表达式,然后取消当前任务并重新添加新的任务实例。需要注意的是,取消已有任务通常需要持有对该任务的引用,例如使用
    Scheduler
    提供的
    unschedule
    方法。
// 假设我们有一个方法用于获取更新后的任务信息  
CronTaskInfo updatedTask = getUpdatedTaskInfoFromDatabase();  
  
// 取消旧的任务(需要知道旧任务的TriggerKey)  
TriggerKey triggerKey = ...; // 获取旧任务的TriggerKey  
scheduler.unschedule(triggerKey);  
  
// 创建新任务并设置新的Cron表达式  
MyTaskExecutor taskExecutor = new MyTaskExecutor(updatedTask.getTaskData());  
CronTrigger updatedCronTrigger = new CronTrigger(updatedTask.getCronExpression());  
  
// 重新调度新任务  
scheduler.schedule(taskRunner, updatedCronTrigger);

另外,我们还可以通过添加任务时对其排序或设置优先级等方式间接实现设置定时任务的执行顺序。

通过实现
SchedulingConfigurer
接口,我们可以拥有对定时任务调度的更多控制权,比如自定义线程池、动态添加任务以及调整任务执行策略。这种灵活性使得在复杂环境下,特别是需要动态管理定时任务时,
SchedulingConfigurer
成为了理想的选择。

其他第三方任务调度框架

除了使用Spring框架提供的
@Scheduled
注解和
SchedulingConfigurer
接口外,还有许多第三方的任务调度库可供选择。这些库通常提供了更多的功能和灵活性,以满足各种复杂的任务调度需求。以下是一些常见的第三方任务调度库:

  1. Quartz Scheduler

    Quartz
    是一个功能强大且灵活的任务调度库,具有丰富的功能,如支持基于
    cron
    表达式的任务调度、集群支持、作业持久化等。它可以与
    Spring
    框架集成,并且被广泛应用于各种类型的任务调度应用程序中。

  2. Elastic Job

    Elastic Job
    是一个分布式任务调度框架,可以轻松实现分布式任务调度和作业执行。它提供了分布式任务执行、作业依赖关系、作业分片等功能,适用于大规模的分布式任务调度场景。

  3. xxl-job

    xxl-job
    是一个分布式任务调度平台,提供了可视化的任务管理界面和多种任务调度方式,如单机任务、分布式任务、定时任务等。它支持任务执行日志、任务失败重试、动态调整任务执行策略等功能。

  4. PowerJob

    PowerJob
    是一个开源的分布式任务调度框架,由阿里巴巴集团开发并开源。PowerJob 提供了分布式、高可用的任务调度能力,支持多种任务类型,如定时任务、延时任务、流程任务等。

总结

定时任务在现代软件开发中扮演着重要的角色,它们可以自动化执行各种重复性的任务,提高系统的效率和可靠性。SpringBoot提供了强大而灵活的定时任务功能,使我们能够轻松地管理和执行各种定时任务。通过
@Scheduled
注解和
SchedulingConfigurer
接口,我们可以根据需求配置定时任务的执行规则,实现各种复杂的定时任务调度需求。我们可以充分利用SpringBoot中的定时任务功能,提高系统的稳定性和可靠性,从而更好地满足业务需求。

本文已收录于我的个人博客:
码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等


内容总结

  • (为什么感觉挺 A+B 的,有点想不明白为何会中…… 不过 writing 貌似很好)
  • 提出了新的 preference model,σ0>σ1 的概率仍然是 exp / (exp + exp) 的形式,但 exp[] 里面的内容从 reward 求和(discounted reward 求和)变成 Σ r · w,其中 w 是一个 importance weight。
  • 这里的 motivation:
    • ① human preference 可能基于 non-Markovian reward,因此用 transformer 建模 trajectory,作为 reward model 的一部分;
    • ② human 可能会关注关键帧,因此需要一个 importance weight,为先前提到的 non-Markovian reward 加权。
  • 然后,利用 attention layer 的 key query value 形式,将 value 作为 reward,softmax(key · query) 作为 importance weight。(正好跟 attention 的形式 match 上)

0 abstract

Preference-based reinforcement learning (RL) provides a framework to train agents using human preferences between two behaviors. However, preference-based RL has been challenging to scale since it requires a large amount of human feedback to learn a reward function aligned with human intent. In this paper, we present Preference Transformer, a neural architecture that models human preferences using transformers. Unlike prior approaches assuming human judgment is based on the Markovian rewards which contribute to the decision equally, we introduce a new preference model based on the weighted sum of non-Markovian rewards. We then design the proposed preference model using a transformer architecture that stacks causal and bidirectional self-attention layers. We demonstrate that Preference Transformer can solve a variety of control tasks using real human preferences, while prior approaches fail to work. We also show that Preference Transformer can induce a well-specified reward and attend to critical events in the trajectory by automatically capturing the temporal dependencies in human decision-making.

  • background:
    • PbRL 框架用于在两种行为之间使用 human preference 来训练 agent。然而,需要大量 human feedback,来学习与人类意图一致的 reward model。
  • method:
    • 在本文中,我们介绍了 preference transformer,使用 transformer 架构模拟 human preference。
    • 以前方法假设,人类判断基于 Markovian reward,而 Markovian reward 对决策的贡献相同。与先前工作不同,我们引入了一种 preference model,该模型基于 non-Markovian reward 的加权和。
    • 然后,我们在 preference model 的 transformer 设计里,堆叠 causal self-attention layers 和 bidrectional self-attention layers。
  • results:
    • Preference Transformer 可以使用真实 human feedback 来解决各种控制任务,而以前的方法无法奏效。
    • Preference Transformer 可以通过自动捕获人类决策中的时间依赖性(temporal dependencies),来得到一个 well-specified reward 并关注轨迹中的关键事件。

open review 与项目网站

  • open review:
    • 主要贡献:① 提出了一个基于 non-Markovian reward 加权和的新 preference model,② 引入 PT 来模拟所提出的 preference model。
    • 如果奖励实际上是 non-Markovian 的,那么 Transformer 的想法是有动机的(well motivated)。
    • The paper is well written. 论文写得很好。
    • scripted evaluation(大概是 scripted teacher)使用 Markovian reward,但 NMR(non-Markovian reward)和 PT 仍能在多个领域优于 MR(Markovian reward)变体。这需要得到更好的解释和评估。事实上,应该使用 non-Markovian reward 进行评估。
  • 项目网站:
    • Preference Transformer 将 trajectory segment 作为输入,从而提取与任务相关的历史信息。
    • 通过堆叠 bidirectional 和 causal self-attention layers,Preference Transformer 生成 non-Markovian reward 和重要性权重(importance weights)作为输出。(貌似 importance weight 越高,某帧在整个 trajectory 里越重要)
    • 我们利用它们来定义 preference model,并发现 PT 可以得到 better-shaped reward,并从 human preference 中关注关键事件。
    • 实验证明,PT 可用于学习复杂的新行为(Hopper 多次后空翻),这很难设计合适的奖励函数。与单个后空翻相比,这项任务更具挑战性,因为奖励函数必须捕获 non-Markovian 上下文,包括旋转次数(有必要嘛?)。观察到,PT agent 在稳定着陆的情况下执行多个后空翻,而基于 MLP 的 Markovian reward 的 agent 很难着陆。

1 基于 non-Markovian reward 的 preference model

  • motivation:
    • 首先,在许多情况下,很难使用 Markovian reward 来给出任务的描述。
    • 此外,由于人类对于非凡的时刻很敏感,因此可能需要在轨迹内分配 credit(大概是权重的意思)。
  • non-Markovian reward function:
    • reward function 的输入:先前的完整的 sub-trajectory。
    • 同时再整一个权重函数 w = w({s, a, s, ...}),其输入也是 t 时刻之前的完整 sub-trajectory。
    • 用 r(τ) · w(τ) 来改写
      \(P(\sigma^1\succ\sigma^0)=\bigg[\exp\big(\sum_tr(\tau_t)\cdot w(\tau_t)\big)\bigg]/\bigg[\exp(\sum r\cdot w)_{\sigma^0}+\exp(\sum r\cdot w)_{\sigma^1} \bigg]\)
      的公式。

2 PT 的架构

  • 感觉 causal transformer 相对好理解,以及 GPT 具有 causally masked self-attention。
  • preference attention layer:
    • causal transformer 生成的 {x, x, ...} sequence,过一个线性层,会得到它们的 key query value。
    • 认为得到的这个 value 就是 reward,而 key 与 query 相乘再 softmax(保证>0)则是权重。
  • 好像这只是一个 reward model,而非 RL policy(?)
    • 学到 reward model 后,还需要使用 IQL 学 policy…

Refer to caption

3 PT 的训练与 inference

  • training:最小化 cross-entropy loss
    \(L=-\mathbb E[(1-y)\log P[\sigma^0\succ\sigma^1]+y\log P[\sigma^1\succ\sigma^0]]\)
    ,其中 y 是 label,P 是我们训练的概率。
  • inference:如何得出 agent 的 reward。
    • 好像是直接拿 reward(而非 reward · importance weight)来做。
    • 大致流程:拿 st, at, s, ... 送进 causal transformer,然后得到 xt, ...,送进 preference attention layer,得到 r hat,单独取出 r hat。

4 experiments

关注的问题:

  • Preference Transformer 能否使用真实的人类偏好解决复杂的控制任务?
  • PT 能否 induce 一致(consistent)的 reward 并关注关键事件?
  • PT 在合成偏好(synthetic preferences,即 scripted teacher setting)中的表现如何?

baseline:

  • 技术路线:preference → reward model → IQL。
  • 1 MLP 的 Markovian reward。
  • 2 基于 LSTM 的 non-Markovian reward。

results:

  • PT 在几乎所有任务中,都始终优于所有 baselines。特别的,只有 PT 几乎将 IQL 的性能与使用 ground-truth reward 相匹配,而 baselines 在困难的任务中基本不 work。
  • 让 PT 和 Markovian 或 LSTM agent 分别生成 trajectory,让 human 评价哪个更好,human 评价 PT 更好。
  • 在所谓的“PT 是否可以诱导(induce)一个明确(well-specified)的奖励”这一段,好像也只是感性分析了一下…
  • 在比较 scripted teacher 和 human 时,因为 scripted teacher 不能理解 contex,所以 human preference 反而在简单任务上表现更好;并且,它们的 preference 会在简单的 grid-world 中发生分歧。
  • 学习复杂的新行为:很炫酷的 hopper 空中多个后空翻的 demo。

5 好像很有道理的 future work

  • 在 RL 或 PbRL 中利用重要性权重,或许可以用于对信息量更大的 query / samples 进行采样,这可以提高 sample-efficiency。
  • 使用重要性权重,通过加权更新,来稳定 Q 学习。
  • 与其他偏好模型结合:例如 Knox et al.(2022)的基于 regret 的 preference model(title: Models of human preference for learning reward functions),尽管他们提出的方法基于几个假设(例如,生成后续特征(Dayan,1993;Barreto et al., 2017)),与基于遗憾的模型相结合会很有趣。(这个暂时没看懂如何做)



在微服务架构或者分布式系统中,客户端如何捕捉服务端的异常?

这里说的客户端指调用方、服务端指被调用方,它们通常运行在不同的进程之中,这些进程可能运行在同一台服务器,也可能运行在不同的服务器,甚至不同的数据机房;其使用的技术栈可能相同,也可能存在很大的差异。

为什么

在Java、C#等高级语言中,程序遇到无法处理的情况,或者不满足运行条件时,比如除数是0的情况,底层代码通常会通过抛出异常(Exception)的方式向上层传递问题,上层代码通过 try-catch 的方式捕捉异常并进行处理,不过这种方式一般只能在同一个进程中使用,如果跨进程就没办法直接使用了。

有的同学可能会问:为什么要跨进程传递异常呢?

大家调用远程接口的时候可能有过这样的体验:

  • 首先远程接口可能会返回一些提前定义好的错误码,此时我们需要从返回数据中提取这些错误码,然后再根据不同的值进行相应的业务处理;
  • 其次我们还需要处理一些未知的错误,它们可能来源于服务端未注意到的地方,比如空指针问题,也可能是底层框架、操作系统或者硬件等抛出的一些问题,比如请求或者返回格式不匹配、网络中断、磁盘故障、内存溢出、文件系统损坏等各种技术问题。

如此我们实际上需要面对两种错误,而且需要采用不同的方式在不同的地方处理它们,这相当繁琐,心智负担比较大。从Java、C#等转Go的同学可能对此也深有体会,随处可见的error判断,还要留心panic的问题,当然Go有自己的意图和坚持,只是写起来真的很糟心。

那我们有什么办法来处理这个问题呢?我的选择是全部统一为处理异常(Exception),异常中可以包含错误码、错误描述,完全可以覆盖错误码的处理方式;而且异常不可避免,错误码则都是上层应用自己定义的。

基本原理

异常信息也是一种数据,所以传递异常也是传输数据。我们想要把数据从一个进程传递给另一个进程有很多种方法,在微服务架构或者分布式系统中,服务之间就是各种远程网络调用,服务的具体实现可能是基于Http协议的Restful、gRPC,也可能是基于TCP的Dubbo等等,我们的异常信息传递也要基于这些框架的约定和底层通信协议。

以Restful为例,当服务端产生异常时,我们通过拦截器或者程序内部的中间件捕捉到这个异常,提取出其中的异常信息,并中断这个异常的继续抛出,然后把拿到的异常信息写到HTTP Header中,返回到客户端。客户端的HTTP请求程序则从HTTP响应的Header中读取到这些异常信息,然后再把他们包装成异常(Exception),throw 出来。最后客户端中的业务代码就可以使用 try-catch 捕捉到这个异常,并根据错误码进行相应的处理。

使用WCF、gPRC和Dubbo等框架时也是类似的方法,只是传递异常时其写入和读取的位置不同。比如Dubbo可以在其数据包的消息头中声明这是一个错误相应,并在消息体中包含详细的异常信息;gPRC则可以利用它提供的Status来传递错误码、错误描述和一些额外的参考信息。

使用Restful、gRPC等协议或者技术还有一个好处,那就是这些技术使用的协议是跨平台的,你用Java开发,他用Go开发,你的程序跑在Windows上,他的程序跑在Linux上,这些都没有问题,都可以按照一套规则正常通信,传递异常也完全没有问题。

有的同学可能会担心性能的问题,因为抛出异常时,程序通常要把整个调用堆栈回溯一遍,这个过程可能会消耗一些计算资源,特别是当异常频繁发生或堆栈层次很深时。不过正常情况下,各种防护到位时,异常应该很少发生;而且现代编译器和运行时环境也会对异常处理进行优化,以减少性能开销。最后,异常处理机制的设计初衷是为了提高代码的健壮性和可维护性,在大多数情况下,异常处理所带来的性能开销是可以接受的。

最佳实践

接下来聊一些具体实现、遇到的问题和应对方法。

抛出业务异常

服务在改变数据状态之前,通常需要对数据进行一些验证,比如必填验证、格式验证、数据一致性验证等等,如果验证不通过,就要返回错误信息。

在传统的方案中,我们可能会定义一个通用的消息格式,其中包含错误码、错误描述,以及正常的业务字段,如下这样:

public class Response{
  // 处理状态:错误码、错误描述
  public int ErrCode{get;set;}
  public string ErrMsg{get;set;}

  // 处理成功时返回的业务数据
  public string UserId{get;set;}
  public string UserName{get;set;}
  ...
}

需要返回错误时,我们就会创建一个Response的实例,然后返回它,就像下边这样:

if(stirng.IsNullOrEmpty(id)){
  return new Response(100,"Id为空");
}

为了实现更为统一的错误处理方式,我们这里可以把返回Response实例的方式改为抛出异常。

if(stirng.IsNullOrEmpty(id)){
  throw new FireflySoftException(100,"Id为空");
}

如此,我们只需要在拦截器或者中间件中捕捉异常,并进行相应的处理就可以了,不管它是一个业务上的验证错误,还是底层框架中的某种未知异常。

比如在ASP.NET Core的异常拦截器中可以这样统一处理:

/// <summary>
/// WebAPI异常过滤器
/// </summary>
internal class WebAPIAsyncExceptionFilter : IAsyncExceptionFilter
{
    /// <summary>
    /// 异步异常处理
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public async Task OnExceptionAsync(ExceptionContext context)
    {
          // 将自定义的异常或系统自带异常都转换为一种异常
          FireflySoftException ex;
          if(context.Exception is FireflySoftException){
            ex = (FireflySoftException)context.Exception;
          }else{
            ex = ConvertToFireflySoftException(context.Exception);
          }

          // 将异常信息写到 Http Header 中
          context.HttpContext.Response.StatusCode = 500;
          context.HttpContext.Response.Headers.Add("errcode", ex.Code.ToString());
          context.HttpContext.Response.Headers.Add("errmsg", System.Web.HttpUtility.UrlEncode(ex.Message));
          // 异常描述也写到 Http Body 中,方便人看
          var bodyContent = Encoding.UTF8.GetBytes(ex.Message);
          await context.HttpContext.Response.Body.WriteAsync(bodyContent, 0, bodyContent.Length).ConfigureAwait(false);
          
          context.ExceptionHandled = true;
    }
}

在底层处理异常

不应该让业务程序开发者关心异常的传递实现,比如上边编写的拦截器应该内置到团队的开发框架或者规范类库中,业务程序开发者只需要抛出异常或者捕捉异常就够了。

服务端的异常拦截器上边已经给了个例子,对于客户端,我们可以通过包装网络请求方法来达到相同的目的。这里还是用ASP.NET Core举个例子:

// 包装的Post请求方法
public async Task<HttpResponseMessage> PostAsync<TRequest>(string hostAndPort, string resourceUri, TRequest request)
{
    string requestJson = JsonConvert.SerializeObject(request);
    var content = new StringContent(requestJson, Encoding.UTF8, "application/json");

    // 在实际的网络请求外边包一层
    return await DoHttp(async client =>
    {
        var uri = new Uri(client.BaseAddress, resourceUri);
        var requestMessage = new HttpRequestMessage()
        {
            Method = HttpMethod.Post,
            RequestUri = uri,
            Content = content
        };

        return await client.SendAsync(requestMessage).ConfigureAwait(false);
    }, hostAndPort).ConfigureAwait(false);
}

// 拦截HTTP错误并包装为自定义的异常
private async Task<HttpResponseMessage> DoHttp(Func<HttpClient, Task<HttpResponseMessage>> action, string hostAndPort)
{
    HttpResponseMessage response;
    try
    {
        var client = GetHttpClient();
        response = await action(client).ConfigureAwait(false);
        return response.EnsureSuccessStatusCode();
    }
    catch (Exception ex)
    {
        // 如果 HTTP StatusCode 是错误码,会进入这里
        // 从 HTTP Header中提取错误码和错误描述
        // 然后可以创建并抛出对应的异常
         if (response.Headers.TryGetValues("errcode", out IEnumerable<string> errcodes))
         {
             var code = errcodes.FirstOrDefault();
             throw new FireflySoftException(code,"xxxxx");
         }
         ...
    }
}

如此,开发者通过Post调用接口时就可以这样写:

// 根据实际情况,可能需要try-catch,也可能不需要
try
{
  PostAsync("localhost:8080","api/getweather",new Request{
    City="帝都"
  })
}
catch(FireflySoftException ex)
{
    // 这里处理可能的业务异常
}

统一记录异常日志

有的同学为了方便跟踪异常信息,喜欢在程序中catch异常,并记录到日志中。

如果使用统一的异常方式来处理错误,则都可以在拦截器或者中间件中来做这件事,只需要在其中加入日志的记录逻辑就可以了。

当然有些异常可能还是要 catch 一下的,比如“添加信息时重复提交”、“给用户发消息时用户已取消授权”等等,这些异常可能都是要被忽略的,catch 住它们之后,程序可以吞掉这些异常,因为服务调用方也不关心这些异常,就没必要再向上抛出。

区分Warn和Error

这里是说要给异常分个等级,有些异常就是个警告级别的,比如用户没有填写某个参数,只要告诉用户就行了,运维或者开发者不太关心这些消息。有些异常则十分严重,比如空指针异常、除0异常等等,这往往说明程序存在BUG,需要反馈给开发者进行修复。

我们可以在自定义的异常构造函数中增加一个异常等级的参数,如下所示:

if(stirng.IsNullOrEmpty(id)){
  throw new FireflySoftException(100,"Id为空",ErrorLevel.Light);
}

注意也不是所有的警告都无需管理员过问,比如对于一个网络请求库,我们可能只是把请求超时作为一种警告,但是如果超时发生的非常频繁,也需要通知管理员来进行关注。

根据异常级别,我们就可以记录不同级别的日志,然后监控程序就可以根据日志级别和相应的频率为管理员提供相应的处理建议。

返回200还是500

使用HTTP作为服务之间的通信协议时,发生异常时服务端一般会返回500错误,也就是 HTTP StatusCode = 500,这一般是底层通信框架的默认设计。但是这会导致一个监控问题,监控程序会跟踪服务调用之间的HTTP状态,如果遇到500错误,它就会认为程序发生了错误,而这个错误可能只是一个参数验证不通过的情况,管理员不需要关心这个问题。

此时我们可以在拦截器中处理异常的地方稍微改造一下,将所有的HTTP状态码都改为200,或者当错误级别比较轻(ErrorLevel.Light)时设置为200,错误级别比较重(ErrorLevel.Heavy)时设置为500。

context.HttpContext.Response.StatusCode = 200;

这样做并不影响客户端对错误的处理,因为不管HTTP的状态码如何,客户端都可以从HTTP Header中提取处理错误所需的错误码和错误描述。

自动重试

有时服务端的错误可能只是瞬时的,或者只是多个节点中的少数节点不可用,重新发起请求就能成功完成调用。

我们可以把这个重试机制包装到网络请求方法中,减少业务程序中处理重试的代码量,此举也能更好的规范代码,避免BUG或者性能问题。

一种可行的方法是,我们根据异常的类型或者提前约定好的错误码,在包装的网络请求方法中针对这些异常进行特殊处理。具体实现可以参考下边的代码:

private async Task<HttpResponseMessage> DoHttp(Func<HttpClient, Task<HttpResponseMessage>> action, string hostAndPort)
{
  int tryCount = 0;
  while (true)
  {
      HttpResponseMessage response;
      try
      {
          var client = GetHttpClient();
          response = await action(client).ConfigureAwait(false);
          return response.EnsureSuccessStatusCode();
      }
      catch (Exception ex)
      {
           // 遇到某种特定的异常时,我们就进行一次重试
           if (ex is TaskCanceledException)
           {
              if(tryCount<1){
                tryCount++;
                continue;
              }
              throw;
           }
           ...
      }
  }
}


以上就是本文的主要内容,文章虽然描述了微服务架构下异常传递的基本原理,也探讨了一些具体的实践方法,但要完完整整的实现并集成到自己的开发框架中,必然还有很多的工作要做,比如错误码的定义,异常处理与限流、熔断等的整合,等等。

文章难免错漏,如有问题欢迎交流讨论。

关注萤火架构,加速技术提升!