风控系统之普通规则条件,使用LiteFlow实现

个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview


提要

参考:智能风控筑基手册:全面了解风控决策引擎

前面有可配置输入参数的接口如何设计风控系统指标计算/特征提取分析与实现01,Redis、Zset、模版方法两篇文章,分别提出:

1、风控系统服务动态选择,根据配置处理输入参数,转换为系统参数

2、使用Rediszset结构完成简单的指标计算(特征提取)

他们都是一次风控决策流程的一部分,当然完成的风控系统,比较复杂,涉及的功能模块更多,以下仅仅是我的简单梳理。

yuque_diagram

如上,服务选择和入参处理可配置输入参数的接口如何设计是这篇文章讨论的内容,风控系统指标计算/特征提取分析与实现01,Redis、Zset、模版方法讨论的是规则集内普通指标计算。

本篇文章讨论通过LiteFlow这款规则引擎框架实现风控系统的普通规则条件。

规则条件

规则条件是什么?

我将规则划分如下(未来会逐渐完善),规则条件是规则的一部分。

yuque_diagram-(1)

需要注意的是规则条件应该都是灵活可配置,并不是上面这样并列,可以任意复杂的组合。

为什么只是规则条件灵活可配置呢?操作难道不是吗?

可以,但没必要。

如下,是规则(最近24小时转账次数>=10次)示例。

yuque_diagram-(2)

观察可知,其实右半边可以作为一个新的规则独立出去的,所以说,规则操没必要和规则条件混在一起。

yuque_diagram-(3) # 规则引擎LiteFlow

规则引擎是为了解耦,编排而生。

LiteFlow官网:🍤LiteFlow简介 | LiteFlow

LiteFlow官方文档写的已经非常清晰,花费不到一上午的时间就可以了完全了解了,所以我也不多说些什么了。

为什么需要规则引擎

因为独立组件+灵活编排的需求和规则引擎不谋而合。

设计与实现

组件

组件是规则引擎中最重要的一部分,他是所有规则表达式最终业务的实现。

不使用规则引擎时

在不使用规则引擎时,针对前面普通规则条件,可以设计如下的结构。

一条规则关联一组规则条件,规则条件又最多分为两级,一级指明了二级规则条件“与或非”关系,二级是具体的规则条件。具体的规则条件关键字段是:系统字段(property)、字段类型(property_data_type)、操作(operator)、希望的值(value)。

有了如下的结构该怎么使用也很清晰了

1、查规则

2、查规则条件组

3、根据父条件,确定子条件关系

4、代码解析操作类型,返回条件结果

id uuid rule_uuid parent_uuid logic_operator property property_data_type operator value
1 270a8dc859a940008539f270ae596ad6 86cbd8adff914f67b576f0046b5b337d
2 bfbe53d6b5ae4895aef1c4c453e3e16e 86cbd8adff914f67b576f0046b5b337d 270a8dc859a940008539f270ae596ad6 &&
3 cf46348d533a48db8f027e4db4f6bb7a 86cbd8adff914f67b576f0046b5b337d bfbe53d6b5ae4895aef1c4c453e3e16e S_N_EVENTHOUR INT >= 22
4 f759541121664847bbc7d944ad1a553f 86cbd8adff914f67b576f0046b5b337d bfbe53d6b5ae4895aef1c4c453e3e16e S_N_EVENTHOUR INT <= 24
5 ec1e79cc18734fa4ab3daa51fe8597c8 86cbd8adff914f67b576f0046b5b337d bfbe53d6b5ae4895aef1c4c453e3e16e C_S_FINANCIALCLIENTS STRING ==
6 5a3b35c7d1d04466b16ae2da64383e21 86cbd8adff914f67b576f0046b5b337d 270a8dc859a940008539f270ae596ad6 &&
7 1bed61e83d7e4ad0a813f2fa3bd7b8a9 86cbd8adff914f67b576f0046b5b337d 5a3b35c7d1d04466b16ae2da64383e21 S_N_EVENTHOUR INT >= 0
8 9446d7a9ec284c1ab52c600ac1cfad26 86cbd8adff914f67b576f0046b5b337d 5a3b35c7d1d04466b16ae2da64383e21 S_N_EVENTHOUR INT < 6
9 690e668a2e7c445c80b04ef5e30d3fa4 86cbd8adff914f67b576f0046b5b337d 5a3b35c7d1d04466b16ae2da64383e21 C_S_FINANCIALCLIENTS STRING ==

使用规则引擎后

首先我们定义组件可以完成字段的比较并返回true/false

那么上面作为组件的只有id为3、4、5、7、8、9,然后将这些编排成如下表达式即可。

这里简单介绍一些IF表达式,一共三个参数,第一个为条件,后面两个为true执行,false执行,跟三元表达式一样。

1
IF(OR(AND(3,4,5),AND(7,8,9)),x,y)

是不是很简单,看着确实,但有一个设计必须要搞通,也就是下面我要说的数据上下文。

数据上下文

🍄说明 | LiteFlow

数据上下文这个概念在LiteFlow框架中非常重要,你所有的业务数据都是放在数据上下文中。

要做到可编排,一定是消除每个组件差异性的。如果每个组件出参入参都不一致,那就没法编排了。

LiteFlow对此有独特的设计理念,平时我们写瀑布流的程序时,A调用B,那A一定要把B所需要的参数传递给B,而在LiteFlow框架体系中,每个组件的定义中是不需要接受参数的,也无任何返回的。

每个组件只需要从数据上下文中获取自己关心的数据即可,而不用关心此数据是由谁提供的,同样的,每个组件也只要把自己执行所产生的结果数据放到数据上下文中即可,也不用关心此数据到底是提供给谁用的。这样一来,就从数据层面一定程度的解耦了。从而达到可编排的目的。关于这个理念,也在LiteFlow简介中的设计原则有提到过,给了一个形象的例子,大家可以再去看看。

一旦在数据上下文中放入数据,整个链路中的任一节点都是可以取到的。

我简单说明一下。

如下,表示瀑布流程,从开始到结束,每步调用都需要将数据传递给下一步调用者,完成整个流程。

yuque_diagram-(4)

而对于LiteFlow,更像是下面这样,整个流程存在这样的数据上下文,每个组件只需要去数据上下文中取自己关心的数据,结果也是一样,放进数据上下文即可。

yuque_diagram-(5)

此模式下,要非常注重数据上下文的管理,数据隔离和共享要非常注意。

相同组件数据问题

对于规则条件组件的问题在于:每个规则里的条件非常多,组件该怎么获取当前组件参数(如:appName==Phone)。如IF(OR(AND(3,4,5),AND(7,8,9)),x,y)此表达式转换为我们使用的规则表达式应该是这样的IF(OR(AND(ruleConditionIF,ruleConditionIF,ruleConditionIF),AND(ruleConditionIF,ruleConditionIF,ruleConditionIF)),x,y)ruleConditionIF为规则条件组件。一个表达式中有多个相同的组件意味着他们需要不同的处理,那么数据怎么获取?

LiteFlow提供了三种不同方式:

1、🍉组件参数 | LiteFlow,定义EL表达式时声明数据并传入组件

2、🍍组件标签 | LiteFlow,定义组件tag区别组件

3、🍕私有投递 | LiteFlow,用于私有独有数据传递

下面使用方式二(组件标签)来实现普通条件组件。

表结构与数据

chain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
create table de_chain
(
id bigint auto_increment comment '主键'
primary key,
application_name varchar(32) default '' not null comment '应用名',
chain_name varchar(64) default '' not null comment 'chain名',
el_data text not null comment 'el数据',
enable bit default b'0' not null comment 'chain状态',
description varchar(64) charset utf8mb4 default '' null comment '描述',
creator varchar(64) charset utf8mb4 default '' null comment '创建者',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updater varchar(64) charset utf8mb4 default '' null comment '更新者',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
deleted bit default b'0' not null comment '是否删除',
constraint uk_code
unique (chain_name)
)comment 'chain表';

INSERT INTO coolGuard.de_chain (id, application_name, chain_name, el_data, enable, description, creator, create_time, updater, update_time, deleted) VALUES (1, 'coolGuard', 'mainChain', 'THEN(chain1);', true, '', '', '2024-04-04 22:35:36', '', '2024-04-04 22:40:30', false);
INSERT INTO coolGuard.de_chain (id, application_name, chain_name, el_data, enable, description, creator, create_time, updater, update_time, deleted) VALUES (2, 'coolGuard', 'chain1', 'IF(OR(AND(ruleConditionIf.tag("1"),ruleConditionIf.tag("2")),ruleConditionIf.tag("3")),orderMode,worstMode);', true, '', '', '2024-04-04 22:31:16', '', '2024-04-05 13:25:49', false);

规则条件表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create table de_rule_condition
(
id bigint auto_increment comment '主键'
primary key,
chain_name varchar(64) default '' not null comment 'chain名',
field_name varchar(32) default '' not null comment '字段名',
operate_type int default 0 not null comment '操作类型',
expect_value varchar(32) default '' not null comment '期望值',
description varchar(64) charset utf8mb4 default '' null comment '描述',
creator varchar(64) charset utf8mb4 default '' null comment '创建者',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updater varchar(64) charset utf8mb4 default '' null comment '更新者',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
deleted bit default b'0' not null comment '是否删除'
)comment '规则条件表';

INSERT INTO coolGuard.de_rule_condition (id, chain_name, field_name, operate_type, expect_value, description, creator, create_time, updater, update_time, deleted) VALUES (1, 'chain3', 'appName', 2, 'Phone', '', '', '2024-04-04 22:32:25', '', '2024-04-05 12:24:03', false);
INSERT INTO coolGuard.de_rule_condition (id, chain_name, field_name, operate_type, expect_value, description, creator, create_time, updater, update_time, deleted) VALUES (2, 'chain4', 'customerId', 2, '123456', '', '', '2024-04-05 12:24:03', '', '2024-04-05 12:24:03', false);
INSERT INTO coolGuard.de_rule_condition (id, chain_name, field_name, operate_type, expect_value, description, creator, create_time, updater, update_time, deleted) VALUES (3, 'chain5', 'money', 5, '15', '', '', '2024-04-05 12:24:03', '', '2024-04-05 12:24:03', false);

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-spring-boot-starter</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-rule-sql</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-groovy</artifactId>
<version>${liteflow.version}</version>
</dependency>

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
liteflow:
rule-source-ext-data-map:
url: jdbc:mysql://localhost:3306/coolGuard?allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
driverClassName: com.mysql.cj.jdbc.Driver
username: wnhyang
password: 123456
applicationName: ${spring.application.name}
#是否开启SQL日志
sqlLogEnabled: true
#是否开启SQL数据轮询自动刷新机制 默认不开启
pollingEnabled: true
pollingIntervalSeconds: 60
pollingStartSeconds: 60
#以下是chain表的配置,这个一定得有
chainTableName: de_chain
chainApplicationNameField: application_name
chainNameField: chain_name
elDataField: el_data
chainEnableField: enable
#以下是script表的配置,如果你没使用到脚本,下面可以不配置

# scriptTableName: script
# scriptApplicationNameField: application_name
# scriptIdField: script_id
# scriptNameField: script_name
# scriptDataField: script_data
# scriptTypeField: script_type
# scriptLanguageField: script_language
# scriptEnableField: enable

自定义数据上下文

可以改善,先这样贴出来。

FieldContext表示经过入参处理后的所有字段集合,后面的规则/指标都会用到的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* @author wnhyang
* @date 2024/4/3
**/
public class FieldContext {

private final Map<String, String> stringFields = new ConcurrentHashMap<>();

private final Map<String, Integer> numberFields = new ConcurrentHashMap<>();

private final Map<String, Boolean> booleanFields = new ConcurrentHashMap<>();

private final Map<String, String> enumFields = new ConcurrentHashMap<>();

private final Map<String, LocalDateTime> dateFields = new ConcurrentHashMap<>();

private final Map<String, BigDecimal> floatFields = new ConcurrentHashMap<>();

public void setStringData(String key, String value) {
stringFields.put(key, value);
}

public String getStringData(String key) {
return stringFields.get(key);
}

public boolean hasStringData(String key) {
return stringFields.containsKey(key);
}

}

普通规则条件组件

当前还不是很完善,TODO已有说明。

对了,我使用的jdk17,所有switch表达式是下面这样,与jdk8有点区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/**
* @author wnhyang
* @date 2024/4/4
**/
@Slf4j
@LiteflowComponent
@RequiredArgsConstructor
public class RuleConditionIf extends NodeIfComponent {

private final RuleConditionMapper ruleConditionMapper;

@Override
public boolean processIf() throws Exception {

// 获取当前chainName
String tag = this.getTag();
log.info("当前tag:{}", tag);

// 获取当前chainName对应的条件
RuleCondition ruleCondition = ruleConditionMapper.selectById(tag);
log.info("当前chainName对应的条件:{}", ruleCondition);

// 获取上下文
FieldContext fieldContext = this.getContextBean(FieldContext.class);

// 获取条件字段
String fieldName = ruleCondition.getFieldName();
log.info("条件字段:{}", fieldName);

// 获取字段值
// TODO 支持String、Integer、BigDecimal、Boolean等
String stringData = fieldContext.getStringData(fieldName);
log.info("字段值:{}", stringData);

OperateType byType = OperateType.getByType(ruleCondition.getOperateType());
log.info("操作类型:{}", byType);

// TODO 当前是常量,之后要考虑变量
String expectValue = ruleCondition.getExpectValue();
log.info("期望值值:{}", expectValue);

return switch (Objects.requireNonNull(byType)) {
case NULL:
yield StrUtil.isBlank(stringData);
case NOT_NULL:
yield !StrUtil.isBlank(stringData);
case EQ:
yield stringData.equals(expectValue);
case NOT_EQ:
yield !stringData.equals(expectValue);
case CONTAINS:
yield stringData.contains(expectValue);
case NOT_CONTAINS:
yield !stringData.contains(expectValue);
case GT:
yield Integer.parseInt(stringData) > Integer.parseInt(expectValue);
case GTE:
yield Integer.parseInt(stringData) >= Integer.parseInt(expectValue);
case LT, LTE:
yield false;
case IN:
String[] split1 = expectValue.split(",");
for (String s : split1) {
if (stringData.equals(s)) {
yield true;
}
}
case NOT_IN:
String[] split2 = expectValue.split(",");
for (String s : split2) {
if (stringData.equals(s)) {
yield false;
}
}
case PREFIX:
yield stringData.startsWith(expectValue);
case NOT_PREFIX:
yield !stringData.startsWith(expectValue);
case SUFFIX:
yield stringData.endsWith(expectValue);
case NOT_SUFFIX:
yield !stringData.endsWith(expectValue);

};
}
}

操作类型枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* @author wnhyang
* @date 2024/4/3
**/
@AllArgsConstructor
@Getter
public enum OperateType {

NULL(0),
NOT_NULL(1),
EQ(2),
NOT_EQ(3),
GT(4),
GTE(5),
LT(6),
LTE(7),
IN(8),
NOT_IN(9),
CONTAINS(10),
NOT_CONTAINS(11),
PREFIX(12),
NOT_PREFIX(13),
SUFFIX(14),
NOT_SUFFIX(15);

private final Integer type;

public static OperateType getByType(Integer type) {
for (OperateType operateType : OperateType.values()) {
if (operateType.getType().equals(type)) {
return operateType;
}
}
return null;
}
}

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@RestController
@RequestMapping("/field")
@RequiredArgsConstructor
public class FieldController {

private final FieldService fieldService;

private final FlowExecutor flowExecutor;

@GetMapping("/test")
public CommonResult<String> test(@RequestParam("appName") String appName, @RequestParam("customerId") String customerId, @RequestParam("money") String money) {
FieldContext fieldContext = new FieldContext();
fieldContext.setStringData("appName", appName);
fieldContext.setStringData("customerId", customerId);
fieldContext.setStringData("money", money);
LiteflowResponse main1 = flowExecutor.execute2Resp("mainChain", null, fieldContext);
log.info(String.valueOf(main1));
return success("test");
}
}
image

结果

mainChainEL表达式为THEN(chain1);chain1为子流程

IF(OR(AND(ruleConditionIf.tag("1"),ruleConditionIf.tag("2")),ruleConditionIf.tag("3")),orderMode,worstMode);ruleConditionIf为上面的规则条件组件。

最终应该是这样的:THEN(IF(OR(AND(ruleConditionIf.tag("1"),ruleConditionIf.tag("2")),ruleConditionIf.tag("3")),orderMode,worstMode));

yuque_diagram-(6)

参数为:appName:Phone,customerId:235246,money:35

此时执行流程为:ruleConditionIf<17>==>ruleConditionIf<2>==>ruleConditionIf<2>==>orderMode<0>

image

参数为:appName:Phone,customerId:235246,money:3

此时执行流程为:ruleConditionIf<42>==>ruleConditionIf<2>==>ruleConditionIf<1>==>worstMode<0>

image

总结

LiteFlow可玩性还是很强的,未来我还会继续完善打造自己设计并实现的风控系统。冲冲冲!!!

写在最后

拙作艰辛,字句心血,望诸君垂青,多予支持,不胜感激。


个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview