2024年1月

wmproxy

wmproxy
已用
Rust
实现
http/https
代理,
socks5
代理, 反向代理, 静态文件服务器,四层TCP/UDP转发,七层负载均衡,内网穿透,后续将实现
websocket
代理等,会将实现过程分享出来,感兴趣的可以一起造个轮子

项目地址

国内: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

设计目标

通过简单配置方便用户快速使用tcp转websocket及websocket转tcp,也可支持http升级到websocket协议。

改造http升级websocket

因为负载均衡的不确定性,在未读取数据前,未能确定当前的处理逻辑

  • /root/proxy.png
    访问当前的件服务器
  • /api/up
    通过负载均衡访问后端服务器
  • /ws
    将连接升级成websocket
  • 其它情况
    所以我们得预备能支持websocket的可能,那我们将同时设置回调HTTP及websocket,源码在
    reverse/http.rs
let timeout = oper.servers[0].comm.build_client_timeout();
let mut server = Server::builder()
    .addr(addr)
    .timeout_layer(timeout)
    .stream(inbound);

// 设置HTTP回调
server.set_callback_http(Box::new(Operate { inner: oper }));
// 设置websocket回调,客户端有可能升级到websocket协议
server.set_callback_ws(Box::new(ServerWsOperate::new(servers)));
if let Err(e) = server.incoming().await {
    if server.get_req_num() == 0 {
        log::info!("反向代理:未处理任何请求时发生错误:{:?}", e);
    } else {
        if !e.is_io() {
            log::info!("反向代理:处理信息时发生错误:{:?}", e);
        }
    }
}


ServerWsOperate
中定义了服务的内部信息,及向远程websocket发送的sender,以做绑定

pub struct ServerWsOperate {
    inner: InnerWsOper,
    sender: Option<Sender<OwnedMessage>>,
}

在on_open的时候建立和远程websocket的双向绑定:


#[async_trait]
impl WsTrait for ServerWsOperate {
    /// 握手完成后之后的回调,服务端返回了Response之后就认为握手成功
    async fn on_open(&mut self, shake: WsHandshake) -> ProtResult<Option<WsOption>> {
        if shake.request.is_none() {
            return Err(ProtError::Extension("miss request"));
        }
        let mut option = WsOption::new();
        if let Some(location) =
            ReverseHelper::get_location_by_req(&self.inner.servers, shake.request.as_ref().unwrap())
        {
            if !location.is_ws {
                return Err(ProtError::Extension("Not Support Ws"));
            }
            if let Ok((url, domain)) = location.get_reverse_url() {
                println!("connect url = {}, domain = {:?}", url, domain);
                let mut client = Client::builder()
                    .url(url)?
                    .connect_with_domain(&domain)
                    .await?;

                let (serv_sender, serv_receiver) = channel::<OwnedMessage>(10);
                let (cli_sender, cli_receiver) = channel::<OwnedMessage>(10);
                option.set_receiver(serv_receiver);
                self.sender = Some(cli_sender);

                client.set_callback_ws(Box::new(ClientWsOperate {
                    sender: Some(serv_sender),
                    receiver: Some(cli_receiver),
                }));

                tokio::spawn(async move {
                    if let Err(e) = client
                        .wait_ws_operate_with_req(shake.request.unwrap())
                        .await
                    {
                        println!("error = {:?}", e);
                    };
                    println!("client close!!!!!!!!!!");
                });
            }
            return Ok(Some(option));
        }
        return Err(ProtError::Extension("miss match"));
    }
}

在此地方,我们是用负载均衡来做配置
location.get_reverse_url()
,远程端的域名和ip要映射成本地的ip,所以这边可能要读取负载的ip而不是从dns中解析ip。

获取正确的连接域名和ip地址。

pub fn get_reverse_url(&self) -> ProtResult<(Url, String)> {
    if let Some(addr) = self.get_upstream_addr() {
        if let Some(r) = &self.comm.proxy_url {
            let mut url = r.clone();
            let domain = url.domain.clone().unwrap_or(String::new());
            url.domain = Some(format!("{}", addr.ip()));
            url.port = Some(addr.port());
            Ok((url, domain))
        } else {
            let url = Url::parse(format!("http://{}/", addr).into_bytes())?;
            let domain = format!("{}", addr.ip());
            Ok((url, domain))
        }
    } else {
        Err(ProtError::Extension("error"))
    }
}

此处的处理方式与nginx不同,nginx是将所有升级请求的头信息全部删除,再根据配置的过行补充

Upgrade: websocket
Connection: Upgrade

所以在nginx中配置支持websocket通常如下配置,也就是通常配置的时候需要查找资料进行copy

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

在wmproxy中并不会对客户端的请求做特殊的处理,也就是发了升级远程的websocket服务器接受了升级,我们当前协议就会升级。所以我们在配置中加入了一个字段
is_ws
,如果升级成websocket但是并不支持websocket的时候直接进行报错,告知不支持协议

[[http.server.location]]
rule = "/ws"
is_ws = true
reverse_proxy = "http://ws"

如此在该问该url的时候就可以转websocket了,比如
websocat ws://127.0.0.1/ws

tcp转websocket

利用上章讲述的
StreamToWs
,且利用
stream
流的转发,将转发类型配置
tcp2ws
非安全的ws,或者
tcp2wss
带tls的wss,实现源码在
reverse/stream.rs

[[stream.server]]
bind_addr = "0.0.0.0:85"
proxy_url = "ws://127.0.0.1:8081/"
bind_mode = "tcp2ws"

这样子,我们就可以将本地监听的85端口的地址,流量转发成8081的websocket远程地址。如果远程端验证域名可以配置上相应的
domain = "wmproxy.com"

if s.bind_mode == "tcp2ws" {
    let mut stream_to_ws = StreamToWs::new(inbound, format!("ws://{}", addr))?;
    if domain.is_some() {
        stream_to_ws.set_domain(domain.unwrap());
    }
    let _ = stream_to_ws.copy_bidirectional().await;
}

如此我们就可以轻松的获取tcp流量转websocket的能力。

websocket转tcp

利用上章讲述的
WsToStream
,且利用
stream
流的转发,将转发类型配置
ws2tcp
转发为tcp,实现源码在
reverse/stream.rs

[[stream.server]]
bind_addr = "0.0.0.0:86"
up_name = "ws1"
proxy_url = "tcp://127.0.0.1:8082"
bind_mode = "ws2tcp"

这样子,我们就可以将本地监听的86端口websocket的地址,流量转发成8082的tcp远程地址。如果远程端验证域名可以配置上相应的
domain = "wmproxy.com"

if s.bind_mode == "ws2tcp" {
    let mut ws_to_stream = WsToStream::new(inbound, addr)?;
    if domain.is_some() {
        ws_to_stream.set_domain(domain.unwrap());
    }
    let _ = ws_to_stream.copy_bidirectional().await;
}

如此我们就可以轻松的获取websocket流量转tcp的能力。

小结

利用wmproxy可以轻松的转化tcp到websocket的流量互转,配置简单。可以利用现成的websocket高速通道辅助我们的tcp程序获取更稳定的流量通道。

点击
[关注]

[在看]

[点赞]
是对作者最大的支持

谱聚类
算法基于图论,它的起源可以追溯到早期的图分割文献。
不过,直至近年来,受益于计算机计算能力的提升,谱聚类算法才得到了广泛的研究和关注。

谱聚类
被广泛应用于图像分割、社交网络分析、推荐系统、文本聚类等领域。
例如,在图像分割中,谱聚类可以有效地将图像划分为背景和前景;
在社交网络分析中,它可以识别出不同的社区结构。

1. 算法概述

谱聚类
的基本原理是将数据点视为图中的顶点,根据数据点之间的相似性构建图的边。
它首先计算图的
拉普拉斯矩阵
的特征向量,然后利用这些特征向量进行聚类。
这种方法能够捕捉到数据的非线性结构,因此在许多应用中表现优异。

所谓
拉普拉斯矩阵
,是一种用于表示一个图的矩阵形式。
对于给定的一个有
\(n\)
个顶点的图
\(G\)
,它的拉普拉斯矩阵定义为
\(L=D-A\)

其中
\(D\)
为图的
度矩阵

\(A\)
为图的
邻接矩阵

2. 创建样本数据

为验证
谱聚类
的效果,用
scikit-learn
中的样本生成器创建2个非线性结构的数据集。

from sklearn.datasets import make_moons, make_circles

fig, axes = plt.subplots(nrows=1, ncols=2)
fig.set_size_inches((8, 4))

X_moon, y_moon = make_moons(noise=0.05, n_samples=1000)
axes[0].scatter(X_moon[:, 0], X_moon[:, 1], marker="o", c=y_moon, s=25, cmap=plt.cm.spring)

X_circle, y_circle = make_circles(noise=0.05, factor=0.5, n_samples=1000)
axes[1].scatter(X_circle[:, 0], X_circle[:, 1], marker="o", c=y_circle, s=25, cmap=plt.cm.winter)

plt.show()

image.png
一个交错的
月牙形式
,一个是
同心圆形式
,都是很难线性分割的数据集。

3. 模型训练

首先,用默认的参数训练看看效果:

from sklearn.cluster import SpectralClustering

# 定义
regs = [
    SpectralClustering(n_clusters=2),
    SpectralClustering(n_clusters=2),
]

# 训练模型
regs[0].fit(X_moon, y_moon)
regs[1].fit(X_circle, y_circle)

fig, axes = plt.subplots(nrows=1, ncols=2)
fig.set_size_inches((8, 4))

# 绘制聚类之后的结果
axes[0].scatter(
    X_moon[:, 0], X_moon[:, 1], marker="o", c=regs[0].labels_, s=25, cmap=plt.cm.spring
)

axes[1].scatter(
    X_circle[:, 0], X_circle[:, 1], marker="o", c=regs[1].labels_, s=25, cmap=plt.cm.winter
)

plt.show()

image.png
从图中可以看出,聚类的效果不是很好,从颜色上看,与原始数据的类别相比差距较大。

接下来,调整下
SpectralClustering
模型的
affinity
参数,
这个参数的作用是定义数据点之间的相似度矩阵的计算方法。
affinity
参数的可选值常用的有两个:

  1. nearest_neighbors
    :通过计算最近邻图来构建亲和矩阵
  2. rbf
    :使用径向基函数 (RBF) 内核构建亲和矩阵。

默认的值是
rbf
,下面我们试试
nearest_neighbors
方式的聚类效果。
将上面的代码中
regs
的定义部分换成如下代码:

regs = [
    SpectralClustering(n_clusters=2, affinity="nearest_neighbors"),
    SpectralClustering(n_clusters=2, affinity="nearest_neighbors"),
]

image.png
修改参数之后的聚类效果与原始数据就非常接近了。

4. 总结

简而言之,
谱聚类
是一个在图上进行聚类的方法,它试图找到图的最佳切割,使得同一簇内的边的权重尽可能大,而不同簇之间的边的权重尽可能小。

这种聚类算法的优势有:

  1. 可以捕获数据的
    非线性结构
  2. 对噪声和异常值相对
    鲁棒
  3. 不需要明确的形状假设,适用于
    各种形状的簇

它的局限性有:

  1. 计算
    复杂度相对较高
    ,尤其是对于大规模数据
  2. 需要
    提前确定簇的数量
    ,这在很多实际应用中是一个挑战
  3. 对于高维数据,可能存在“
    维度诅咒
    ”问题,尽管可以通过降维缓解,但增加了计算复杂度

Viterbi 维特比算法解决的是篱笆型的图的最短路径问题,图的节点按列组织,每列的节点数量可以不一样,每一列的节点只能和相邻列的节点相连,不能跨列相连,节点之间有着不同的距离,距离的值就不在
image

题目背景

从前有个村儿,村里的人的身体情况只有两种可能:
健康、发烧

假设这个村儿的人没有体温计或者百度这种神奇东西,他唯一判断他身体情况的途径就是到村头我的偶像金正月的小诊所询问。月儿通过询问村民的感觉,判断她的病情,再假设村民只会回答
正常、头晕或冷

有一天村里奥巴驴就去月儿那去询问了。

  • 第一天她告诉月儿她感觉正常。
  • 第二天她告诉月儿感觉有点冷。
  • 第三天她告诉月儿感觉有点头晕。
    那么问题来了,月儿
    如何
    根据阿驴的描述的情况,
    推断出这三天中阿驴的一个身体状态呢
    ?

已知情况

隐含的身体状态 =
{健康,发烧}
可观察的感觉状态 =
{正常,冷,头晕}

月儿预判的阿驴身体状态的概率分布
(初始状态矩阵)
=
{健康:0.6,发烧:0.4}
月儿认为的阿驴身体健康状态的转换概率分布
(转移矩阵)
=

{
健康->健康: 0.7 ,
健康->发烧: 0.3 ,
发烧->健康:0.4 ,
发烧->发烧: 0.6
}

月儿认为的在相应健康状况条件下,阿驴的感觉的概率分布
(发射矩阵)
=

{
健康,正常:0.5 ,冷 :0.4 ,头晕: 0.1 ;
发烧,正常:0.1 ,冷 :0.3 ,头晕: 0.6
}

由上面我们可以发现,HMM的三要素都齐备了,下面就是解决问题了。
阿驴连续三天的身体感觉依次是: 正常、冷、头晕 。

过程:

第一天的时候,对每一个状态(健康或者发烧),分别求出第一天身体感觉正常的概率:P(第一天健康) = P(正常|健康)
P(健康|初始情况) = 0.5 * 0.6 = 0.3 P(第一天发烧) = P(正常|发烧)
P(发烧|初始情况) = 0.1 * 0.4 = 0.04
第二天的时候,对每个状态,分别求在第一天状态为健康或者发烧情况下观察到冷的最大概率。在维特比算法中,我们先要求得路径的单个路径的最大概率,然后再乘上观测概率。P(第二天健康) = max{0.3
0.7, 0.04
0.4}
0.4=0.3
0.7
0.4=0.084 此时我们需要记录概率最大的路径的前一个状态,即0.084路径的前一个状态,我们在小本本上记下,第一天健康。 P(第二天发烧)=max{0.3
0.3, 0.04
0.6}
0.3=0.027, 同样的在0.027这个路径上,第一天也是健康的。
第三天的时候,跟第二天一样。P(第三天健康)=max{0.084
0.7, 0.027
0.4}
0.1=0.00588,在这条路径上,第二天是健康的。P(第三天发烧)=max{0.084
0.3, 0.027
0.6}
0.6=0.01512,在这条路径上,第二天是健康的。
最后一天的状态概率分布即为最优路径的概率,即P(最优)=0.01512,这样我们可以得到最优路径的终点,是发烧
由最优路径开始回溯。请看我们的小本本,在求得第三天发烧概率的时候,我们的小本本上面写的是第二天健康,好了,第二天就应该是健康的状态,然后在第二天健康的情况下,我们记录的第一天是健康的。这样,我们的状态序列逆推出来了。即为:
健康,健康,发烧
简略的画个图吧:
image
这儿的箭头指向就是一个回溯查询小本本的过程,我们在编写算法的时候,其实也得注意,每一个概率最大的
单条路径
上都要把前一个状态记录下来。

代码

Viterbi

package com.vipsoft.viterbi;

/**
 * 维特比算法
 * @author hankcs
 */
public class Viterbi
{
    /**
     * 求解HMM模型
     * @param obs 观测序列
     * @param states 隐状态
     * @param start_p 初始概率(隐状态)
     * @param trans_p 转移概率(隐状态)
     * @param emit_p 发射概率 (隐状态表现为显状态的概率)
     * @return 最可能的序列
     */
    public static int[] compute(int[] obs, int[] states, double[] start_p, double[][] trans_p, double[][] emit_p)
    {
        double[][] V = new double[obs.length][states.length];
        int[][] path = new int[states.length][obs.length];

        for (int y : states)
        {
            V[0][y] = start_p[y] * emit_p[y][obs[0]];
            path[y][0] = y;
        }

        for (int t = 1; t < obs.length; ++t)
        {
            int[][] newpath = new int[states.length][obs.length];

            for (int y : states)
            {
                double prob = -1;
                int state;
                for (int y0 : states)
                {
                    double nprob = V[t - 1][y0] * trans_p[y0][y] * emit_p[y][obs[t]];
                    if (nprob > prob)
                    {
                        prob = nprob;
                        state = y0;
                        // 记录最大概率
                        V[t][y] = prob;
                        // 记录路径
                        System.arraycopy(path[state], 0, newpath[y], 0, t);
                        newpath[y][t] = y;
                    }
                }
            }

            path = newpath;
        }

        double prob = -1;
        int state = 0;
        for (int y : states)
        {
            if (V[obs.length - 1][y] > prob)
            {
                prob = V[obs.length - 1][y];
                state = y;
            }
        }

        return path[state];
    }
}

DoctorExample

package com.vipsoft.viterbi;

import static com.vipsoft.viterbi.DoctorExample.Feel.cold;
import static com.vipsoft.viterbi.DoctorExample.Feel.dizzy;
import static com.vipsoft.viterbi.DoctorExample.Feel.normal;
import static com.vipsoft.viterbi.DoctorExample.Status.Fever;
import static com.vipsoft.viterbi.DoctorExample.Status.Healthy;

public class DoctorExample
{
    enum Status
    {
        /**
         * 健康
         */
        Healthy,
        /**
         * 发热
         */
        Fever,
    }

    enum Feel
    {
        /**
         *  正常
         */
        normal,
        /**
         * 冷
         */
        cold,
        /**
         * 头晕
         */
        dizzy,
    }

    static int[] states = new int[]{Healthy.ordinal(), Fever.ordinal()};
    /**
     * 初始矩阵
     * { 健康:0.6 , 发烧: 0.4 }
     */
    static double[] start_probability = new double[]{0.6, 0.4};
    /**
     * 转移矩阵
     * {
     *  健康->健康:0.7 ,
     *  健康->发烧:0.3 ,
     *  发烧->健康:0.4 ,
     *  发烧->发烧:0.6
     * }
     */
    static double[][] transititon_probability = new double[][]{
            {0.7, 0.3},
            {0.4, 0.6},
    };
    /**
     * 发射矩阵
     * {
     *   健康,正常:0.5 ,冷 :0.4 ,头晕: 0.1 ;
     *   发烧,正常:0.1 ,冷 :0.3 ,头晕: 0.6
     * }
     */
    static double[][] emission_probability = new double[][]{
            {0.5, 0.4, 0.1},
            {0.1, 0.3, 0.6},
    };

    public static void main(String[] args)
    {
        // 连续三天的身体感觉依次是: 正常、冷、头晕,推算出这三天的身体状态
        int[] observations = new int[]{normal.ordinal(), cold.ordinal(), dizzy.ordinal()};
        int[] result = Viterbi.compute(observations, states, start_probability, transititon_probability, emission_probability);
        for (int r : result)
        {
            System.out.print(Status.values()[r] + " ");
        }
        System.out.println();
    }
}

源码:
https://gitee.com/VipSoft/VipBoot/tree/develop/vipsoft-viterbi/src/main/java/com/vipsoft/viterbi

引用:
https://www.zhihu.com/question/20136144
里的问题回答,正好是 HanLP 的Demo示例

前言

如今园区中交换机越来越多,传统的查看日志的方式是远程到交换机在命令行中查看,但是logbuffer中保存的容量有限,也不方便查询,耗费运维人员大量的经理,不好集中管理,syslog就为解决此问题,可以搭建一台syslog服务器(我使用群辉自带的功能),将日志集中收集起来。
同样的交换机的配置也可以使用tftp自动保存配置,历史配置也可回溯。
本文集中将华为、思科两大交换机厂商的TFTP自动备份、NTP时间同步、SYSLOG日志同步功能总结起来分享。

配置使用TFTP进行交换机配置的自动保存

华为设备

<Huawei-sw>sys
[Huawei-sw]set save-configuration interval 10080 delay 3 cpu-limit 60
//interval后接定时循环保存周期,单位分钟,如果有变动就保存,无变动不执行保存任务;
//delay后接save配置后多少时间后执行保存任务,单位分钟,这里写3就是3分钟;
//cpu-limit后接cpu利用率,比如写60,当cpu利用率高于60时不执行保存任务;
[Huawei-sw]set save-configuration backup-to-server server <TFTP服务器IP> transport-type tftp path /Huawei-sw
//这里使用tftp方式备份,填写服务器地址、path为fttp://Huawei-sw/
[Huawei-sw]quit
<Huawei-sw>save
  • 华为也可以使用ftp、sftp等其他协议,不过图个方便我还是用了不需要认证的TFTP,然后在防火墙里面加了白名单IP。
  • 华为发送的配置文件是个名为
    vrpcfg.zip
    的压缩包,所以这里的文件后缀zip结尾。
    cfg
    是直接可直接编辑。
  • path后面路径路由可以不写,缺省是与
    /sysname
    同名的目录,注意要在TFTP服务器新建一个与设备同名的文件夹,不然会保存失败。也可以直接指定为根目录
    /

具体参数可见官网
https://support.huawei.com/enterprise/zh/doc/EDOC1100278539/d0a3124b

快速复制
set save-configuration interval 60 delay 3 cpu-limit 60
set save-configuration backup-to-server server <TFTP服务器IP> transport-type tftp path /

思科设备

Cisco-sw>enable
Cisco-sw#conf t
Cisco-sw(config)#archive
Cisco-sw(config-archive)#path tftp://<TFTP服务器IP>/Cisco-sw.conf
Cisco-sw(config-archive)#time-period 10080
Cisco-sw(config-archive)#write-memory
Cisco-sw(config-archive)#exit
Cisco-sw(config)#do wr
  • time-period后接保存周期,单位分钟。比如每周保存,即可7*24*60=10080。
  • write-memory表示每一次执行write后立即保存。

快速复制
archive
path tftp://<TFTP服务器IP>/Cisco-sw.conf
write-memory
time-period 10080

单次TFTP备份

华为设备

tftp <TFTP服务器IP> put vrpcfg.zip /Huawei-sw.zip
  • 这里需要注意你在华为备份的配置文件的名称文件后缀,华为默认是
    vrpcfg.zip
    需要再解压缩,有一些可能是
    vrpcfg.cfg
    纯文本配置文件。可以使用
    display startup
    查看。

  • 记得在
    <用户视图>
    下执行,在
    [系统视图]
    下是另外的tftp配置

思科设备

copy startup-config tftp://<TFTP服务器IP>/Cisco-sw.conf

配置NTP

设置时区和NTP时间同步

华为设备

<Huawei-sw>sys
[Huawei-sw]clock timezone Beijing,Chongqing,Hongkon,Urumqi add 08:00:00
//使用也可以clock timezone CST add 08:00:00
[Huawei-sw]ntp-service unicast-server <NTP服务器IP>

快速复制
clock timezone CST add 08:00:00
ntp-service unicast-server <NTP服务器IP>

思科设备

Cisco-sw>enable
Cisco-sw#conf t
Cisco-sw(config)#clock timezone GMT +8
Cisco-sw(config)#ntp server <NTP服务器IP>
Cisco-sw(config)#service timestamps log datetime localtime
Cisco-sw(config)#do wr
  • 思科注意,只修改时区使用
    show log
    还是显示默认UTC时区,需要修改本地时区
    service timestamps log datetime localtime

快速复制
clock timezone GMT +8
ntp server <NTP服务器IP>
service timestamps log datetime localtime
do wr

配置Syslog

华为设备

<Huawei-sw>sys
[Huawei-sw]info-center loghost <Syslog服务器IP> local-time

快速复制
info-center loghost <Syslog服务器IP> local-time
华为这里有个坑,要这里要加
local-time
参数使用本地时间,再修改时区后虽然
display logbuffer
显示的日志是修改过的时区,但是发送给syslog服务器的日期依旧是使用默认UTC时区

思科设备

Cisco-sw>enable
Cisco-sw#conf t
Cisco-sw(config)#logging host <Syslog服务器IP>

快速复制
logging host <Syslog服务器IP>

验证

我这里使用群辉作为syslog、tftp服务器:

配置已经备份到指定tftp目录,有时间前缀、后缀的都是系统自动备份的记录

syslog已经汇总

修改日志

  • 2023年12月15日 初稿
  • 2024年01月17日 修改部分图例
    • 若对您对本文有何疑问欢迎在下方留言。转载请备注出处,谢谢。by alittlemc

官网:
https://liteflow.cc/

Gitee:
https://gitee.com/dromara/liteFlow

Github:
https://github.com/dromara/liteflow

LiteFlow一个现代化的开源规则引擎框架,以下文中简称LF。

前言

时常在社区里看到有的小伙伴在那提问:

LF在一个流程中如何暂停,等待操作员完成后,进行下一步该怎么做?

LF流程失败后,下一次能否继续上次的执行?

LF流程适不适合某个我的业务?

LF流程如何定时执行我的某个流程?

还有的同学表示即便全部看完文档,也不知道LF该用在何种业务场景。能够带来什么好处。

究其原因是错误理解了流程的概念和不知道规则引擎的概念。

流程

我们先说流程。

LiteFlow定位是一个规则引擎,而不是流程引擎。它并不完成流程所要做的事,其实压根LF和流程一点关系也没有。

那什么是流程呢,标准的定义是,流程由
流程定义

节点要做的事

角色
组成。每一个角色做一件事,根据定义的流程定义串起来就叫流程。最典型的例子就是审批流:采购员提交了一张采购申请,部门领导审核,审核通过则到了财务这里,财务专员根据预算进行审核,审核通过到了总监这里,总监审核通过,再到CEO这里签字批准。整个采购单状态变成待采购。然后进行采购流程。

以上就是标准的一个流程。3大要素,
流程定义



角色
一个都不能少。通常在实际落地过程中,流程引擎负责流程的流转和角色的分派。开发人员只需要定义流程,和开发每个角色需要做的逻辑即可。

流程引擎重点强调2点:

流程的定义,下一步是什么,整体的流向,有多少分支。

角色的分派,即下一步该由谁完成

大部分流程引擎为了灵活性,也提供了流程定义的热更新以及添加角色,修改流程节点更改绑定角色的功能。

虽然LF从EL规则上来看,似乎也是一个个小模块的流转,但是LF并不涉及角色分派这件事。

规则

我们再来看规则引擎的概念。

规则引擎主要强调一件事,把业务中最主要的决策逻辑从程序中抽离出来,用预定义的DSL来实现。并且可以实时改变这些最主要的决策逻辑。

说的再白话点,就是决定逻辑走向的最关键的决策逻辑,不是在代码中的,可以放在外面的任意地方(文件里,数据库里,其他存储,远程获取)。并且这些决策逻辑并不是用你应用的代码语义来实现的。规则引擎提供一套语言,来书写这些决策逻辑。规则引擎也应该提供热更新这些决策代码的功能。

可以看出,规则引擎根本不涉及角色的概念,它更多的适用于一个相对比较复杂的逻辑块。把最核心的部分抽出来用规则引擎定义。

但是整个逻辑块基本要做的还是一件事情。只是部分抽出来而已。

标准的规则引擎处理的例子:

如果有人v我50块,我就去家门口的KFC吃一顿

如果有人v我200块,我就坐地铁去港式餐厅吃一顿

如果有人v我500块,我就打个车,去吃顿日式烤肉

如果有人v我2000块,我就去买身衣服,去吃顿惠林顿牛排,再整瓶酒。

如果有人v我100w,赶紧抽自己一巴掌,看自己醒了没。

有同学看到这,可能会说,那我搞个文件,存groovy代码,我应用每次执行到关键决策的时候,去读取这个文件里的groovy代码,然后解析执行。这不就是规则引擎吗,我要改变决策的时候,每次改那个文件里的groovy代码就行了。

还真是这样!这就是规则引擎!

简单来说,规则引擎就强调3个点:

决策代码不在你的应用程序里

拥有独特的DSL语义书写

实时更新,不用改变应用程序

所以,一些DSL项目也被称作为规则引擎,如Aviator,QLExpress。这些框架提供了热更的接口,稍作包装,就可以开发出一套最基本的规则引擎。

但是我更愿意把这些项目归类为
表达式引擎
,业界还有著名的SpEL(spring的EL),springframwork expression language,其实从全程就可以看出,官方定义了就是
表达式语言

LF满足决策代码可以不写在应用程序里,也拥有独特的DSL,也支持实时更新。但是LF怎么还拥有流转的功能?LF看起来怎么有点四不像啊?

那LF是什么呢

LF也是作用于一个大的逻辑块的,和角色没关系,满足规则引擎的需要的3个关键点。从这点来说,LF是规则引擎。

LiteFlow中的脚本节点已经满足了规则引擎的全部的定义了。那么LF只做脚本节点就可以了。可以热更,拥有独特的DSL,可以保存在任意地方。

但是LF也可以流转,从一个节点流转到另外一个节点,但是这里的节点是一个个小的拆分的逻辑块。其实这是LF独特的地方,这部分并不是标准规则引擎定义所必须的。我称之为:编排。

编排如何理解呢,说的专业点,这就是
逻辑关系反转
,看到这里,有些同学可能又开始看不懂了。你丫的怎么还自己造词呢 。

且听我慢慢道来。

spring的出现解决了依赖反转,我们不用spring的时候,对象的装载是自己new的,spring出现后,只需要在xml里声明后,各个代码块都可以注入了。就不用自己new了。这就是依赖反转。相信这个大家学习八股时已经背了无数遍了。

那么逻辑关系的进行,我们大部分代码还是A类调用B类的方法,B类调用C类的方法。这就是逻辑关系的绑死。如果A,B,C三个类在代码层面互不相干。但是我在xml里去规定,这三个的进行顺序。这就是
逻辑关系反转

逻辑关系反转有什么好处呢?

逻辑关系反转后,好处很多,首先每个类不再强依赖,所有的代码都讲究松耦合理论。那么逻辑关系反转后,就完美实现了松耦合。并且LF规定了每个类的声明方式,就显得所有的逻辑类都长的差不多。更加统一。

其次,逻辑关系反转后,你只需要看xml定义就可以大致理解这个大的逻辑块都干了什么事。非常直观。

再次,逻辑关系都可以热更,那么这是一件多么优雅的事情啊。

那么做逻辑关系反转的引擎我称之为:
编排引擎

所以,LiteFlow是
规则引擎
+
编排引擎
的结合体。

它既可以热更新最关键的决策逻辑,又可以热更新逻辑关系的组成。

所以LiteFlow做的是更现代化的规则引擎,它在做好规则引擎的基础上,超越了原本规则引擎所规定的范畴。

如何用好LF

其实如果对LF理解够深刻,它几乎是一个应用最核心的驱动器。妥妥神器,无论重构,要灵活,要解耦,它都不在话下。

如果对LF理解不够,那你可能感觉LF没什么用。也不知道该怎么用。

所以这就是我一开头提到的。理解流程和规则逻辑流的概念很重要。

LiteFlow提供的脚本多达7种,这些脚本可以充分书写你的关键决策逻辑。并且LF打通了所有脚本和Java的互通,用脚本写不了的。也可以调用java方法来完成。

LiteFlow提供的独特EL适用于做逻辑反转,让你的巨大复杂的逻辑变成一个个小的积木块,通过DSL来进行组搭,形成你业务的逻辑流。积木块可以是你的java组件,也可以是脚本写的决策逻辑。

所以一般项目推荐的做法,就是首先解耦你的复杂逻辑,按照业务边界拆成一个个小的组件,然后把最经常变动的决策逻辑用脚本组件来实现。加上DSL编写的逻辑关系反转表达式。这样形成的系统,其优雅度是非常高的。

你想想,你的关键决策代码,和逻辑关系均可以进行热改变,你的系统中耦合性几乎降低到了最低。改一个小组件不会牵扯到其他的组件的改变。

再回答开头的问题

如果你看到这,全都能理解的话,那么最开始的几个问题也就能轻而易举的能回答了。

LF因为没有角色的概念,全部流转的只是小逻辑块。所以没法暂停的。因为就相当于你普通写的瀑布式代码调用。只是换了一种方式来写而已。

LF不保存状态,是一个无状态的东西。一般来说,规则引擎都是无状态的。状态这回事其实还是要业务自己做的。况且有状态这回事情非常危险,一般框架不会去保证这个。

LF一般来说,所有用普通瀑布流代码一层层去调用都可以用LF去改造,LF并不针对于某个特殊的领域,都可以用的。除非你的定义是属于标准的流程引擎干的事。那么的确不应该选型LF。

关于定时,也不是LF该干的事。LF提供接口层面的执行器,外面套层定时框架即可。很简单。

LF对标的框架是什么?

LF的目标始一直没变过,那就是:超越Drools。

LF独特的理念是国产自研,全部的代码也是国产自研。非常适合拿来做信创改革。

Drools是国外老牌且一向作为行业标杆的规则引擎。相信了解过这行的都有听说过。

其实Drools只提供了标准的规则引擎,并没有提供逻辑关系反转的特性,而且它的DSL学习成本还是比较大的。而LF则用现成的语言来提供作为脚本,比如js,groovy,aviator,python,lua,甚至java本身也可以拿来做写脚本,这些根本不用再次学习。

LF除了规则引擎之外,更加拓展了规则引擎的范畴,使得LF能做的事情更多。

并且LF支持的存储中间件之丰富的,也绝非Drools能比拟的。

加上LF全中文文档,社区答疑体系在开源中首屈一指。Drools一堆英文文档,去哪答疑?

一定要说LF比不上的Drools的地方。那就是决策表体系。

Drools是不需要指定规则的,由决策表来多项匹配。LF由于提供了逻辑反转,是需要指定特定的规则的。

但是这个LF也准备在2.11.5中提供,弥补最后核心上的短板。

届时LF可以平替Drools。

我会为此一直坚守。

谨此此篇献给关注过LF的同学。