C# RulesEngine 规则引擎:从入门到看懵
说明
RulesEngine 是 C# 写的一个规则引擎类库,读者可以从这些地方了解它:
仓库地址:
https://github.com/microsoft/RulesEngine
使用方法:
https://microsoft.github.io/RulesEngine
文档地址:
https://github.com/microsoft/RulesEngine/wiki
什么是规则引擎?
照搬 https://github.com/microsoft/RulesEngine/wiki/Introduction#what-is-the-rules-engine
在企业项目中,关键或核心部分总是业务逻辑或业务规则,也就是 CRUD,这些系统都有一个共同的特征是,某个模块中的一些或许多规则或策略总会发生变化,例如购物网站的顾客折扣、物流企业的运价计算等。随着这些变化而来的是大量的重复工作,如果系统没有足够的抽象,那么每当增加一种规则时,开发者需要在规则、回归测试、性能测试等方面的变化中编写代码。
在 RulesEngine 中,微软对规则进行了抽象,这样核心逻辑总是得到稳定的、易于维护的,而规则的更改可以以一种简单的方式生成,而不需要更改代码库。此外,系统的输入本质上是动态的,因此不需要在系统中定义模型,而是可以作为扩展对象或任何其他类型的对象作为输入,系统经过预定义的规则处理后,输出结果。
它有以下特性:
- Json based rules definition (基于 Json 的规则定义)
- Multiple input support (多输入支持)
- Dynamic object input support (动态对象输入支持)
- C# Expression support (C # 表达式支持)
- Extending expression via custom class/type injection (通过自定义类/类型注入扩展表达式)
- Scoped parameters (范围参数)
- Post rule execution actions (发布规则执行操作)
说人话就是,业务逻辑的输出结果受到多个因子影响,但是这些影响有一定规律的,那么适合将这些部分抽象出来,接着使用规则引擎处理,例如购物的各种优惠卷叠加之后的最终折扣价、跨区运输的不同类型的包裹运价计算等。
笔者认为这个规则引擎主要由两部分构成:
- 规则验证系统,例如根据规则验证字段、执行函数验证当前流程、输出执行结果;
- 动态代码引擎,能够将字符串转换为动态代码,利用表达式树这些完成;
当然,这样说起来其实很抽象的,还得多撸代码,才能明白这个 RulesEngine 到底是干嘛的。
安装
新建项目后,nuget 直接搜索 RulesEngine
即可安装,在 nuget 介绍中可以看到 RulesEngine
的依赖:
FluentValidation 是一个用于构建强类型验证规则的 .NET 库,在 ASP.NET Core 项目中,我们会经常使用模型验证,例如必填字段使用 [Required]
、字符串长度使用 [MaxLength]
等;但是因为是特性注解,也就是难以做到很多需要经过动态检查的验证方式,使用 FluentValidation 可以为模型类构建更加丰富的验证规则。
而 FluentValidation 用在 RulesEngine 上,也是相同的用途,RulesEngine 最常常用做规则验证,检查模型类或业务逻辑的验证结果,利用 FluentValidation 中丰富的验证规则,可以制作各种方便的表达式树,构建动态代码。
怎么使用
我们通过 RulesEngine 检查模型类的字段是否符合规则,来了解 RulesEngine 的使用方法。
创建一个这样的模型类:
public class Buyer
{
public int Id { get; set; }
public int Age { get; set; }
// 是否为已认证用户
public bool Authenticated { get; set; }
}
场景是这样的,用户下单购买商品,后台需要判断此用户是否已经成年、是否通过了认证。
正常来看代码应该这样写:
if(Authenticated == true && Age > 18)
但是如果年龄调为 16 岁呢?如果最近公司搞活动,不需要上传身份证就能购买商品呢?
当然定义变量存储到数据库也行,但是如果后面又新增了几个条件,那么我们就需要修改代码了,大佬说,这样不好,我们要 RulesEngine 。
好的,那我们来研究一下这个东西。
前面提到的 if(Authenticated == true && Age > 18)
,这么一个完整的验证过程,在 RulesEngine 称为 Workflow,每个 Workflow 下有多个 Rule。
if(Authenticated == true && Age > 18) => Workflow
Authenticated == true => Rule
Age > 18 => Rule
在 RulesEngine 中,有两种方法定义这些 Workflow 和 Rule,一种是使用代码,一种是 JSON,官方是推荐使用 JSON 的,因为 JSON 可以动态生成,可以实现真正的动态。
下面我们来看看如何使用 JSON 和代码,分别定义 if(Authenticated == true && Age > 18)
这个验证过程。
JSON 定义:
[
{
"WorkflowName": "Test",
"Rules": [
{
"RuleName": "CheckAuthenticated",
"Expression": "Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "Age >= 18"
}
]
}
]
var rulesStr = "[{... ...}]" // JSON
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr);
C# 代码:
var workflows = new List<Workflow>();
List<Rule> rules = new List<Rule>();
Workflow exampleWorkflow = new Workflow();
exampleWorkflow.WorkflowName = "Test";
exampleWorkflow.Rules = rules;
workflows.Add(exampleWorkflow);
Rule authRule = new Rule();
authRule.RuleName = "CheckAuthenticated";
authRule.Expression = "Authenticated == true";
rules.Add(authRule);
Rule ageRule = new Rule();
ageRule.RuleName = "CheckAuthenticated";
ageRule.Expression = "Authenticated == true";
rules.Add(ageRule);
两种方式都是一样的,每个 Workflow 下有多个 Rule,可以定义多个 Workflow。
当前我们有两个地方要了解:
"RuleName": "CheckAuthenticated",
"Expression": "Authenticated == true"
RuleName
:规则名称;
Expression
: 真实的代码,必须是符合 C# 语法的代码;
定义好 Workflow 和 Rule 后,我们需要生成规则引擎,直接 new RulesEngine.RulesEngine()
即可:
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
生成引擎是需要一些时间的。
生成引擎后,我们通过名称指定调用一个 Workflow,并获取每个 Rule 的验证结果:
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Id = 666,
Age = 17,
Authenticated = false
});
完整代码示例如下:
static async Task Main()
{
// 定义
var rulesStr = ... ...// JSON
// 生成 Workflow[ Rule[] ]
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
// 调用指定的 Workflow,并传递参数,获取每个 Rule 的处理结果
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Id = 666,
Age = 17,
Authenticated = false
});
// 打印输出
foreach (var item in resultList)
{
Console.WriteLine("规则名称:{0}, 验证结果:{1}", item.Rule.RuleName, item.IsSuccess);
}
}
多参数
如果商品需要 VIP 才能购买呢?
这里我们再定义一个模型类,表示一个用户是否为 VIP。
public class VIP
{
public int Id { get; set; }
public bool IsVIP { get; set; }
}
那么这个时候就需要处理两个模型类了,为了能够在 Rule 中使用所有的模型类,我们需要为每个模型类定义 RuleParameter
。
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 20,
Authenticated = true
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = false
});
相当于表达式树:
ParameterExpression rp1 = Expression.Parameter(typeof(Buyer), "buyer");
ParameterExpression rp2 = Expression.Parameter(typeof(VIP), "vip");
可以参考笔者的表达式树系列文章:https://ex.whuanle.cn/
然后重新设计 JSON,增加一个 Rule:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAuthenticated",
"Expression": "buyer.Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "buyer.Age >= 18"
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true"
}
]
}]
然后执行此 Workflow:
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", rp1, rp2);
完整代码:
static async Task Main()
{
// 定义
var rulesStr = ... ... // JSON
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 20,
Authenticated = true
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = false
});
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", rp1, rp2);
foreach (var item in resultList)
{
Console.WriteLine("规则名称:{0}, 验证结果:{1}", item.Rule.RuleName, item.IsSuccess);
}
}
全局参数、本地参数
全局参数
在 Workflow 中可以定义全局参数,参数对 Workflow 内的所有 Rule 起效,所有 Rule 都可以使用它。
定义示例:
"WorkflowName": "Test",
"GlobalParams": [{
"Name": "age",
"Expression": "buyer.Age"
}],
参数的值,可以定义为常量,也可以来源于传入的参数。
修改上一个小节的示例,在 Rule CheckAge
中,使用这个全局参数。
[{
"WorkflowName": "Test",
"GlobalParams": [{
"Name": "age",
"Expression": "buyer.Age"
}],
"Rules": [{
"RuleName": "CheckAuthenticated",
"Expression": "buyer.Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "age >= 18"
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true"
}
]
}]
本地参数
本地参数在 Rule 内定义,只对当前 Rule 起效。
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAuthenticated",
"LocalParams": [{
"Name": "age",
"Expression": "buyer.Age"
}],
"Expression": "buyer.Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "age >= 18"
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true"
}
]
}]
在定义参数时,参数的值可以通过执行函数来获取:
"LocalParams":[
{
"Name":"mylocal1",
"Expression":"myInput.hello.ToLower()"
}
],
LocalParams
可以使用 GlobalParams
的参数再次生成新的变量。
"GlobalParams":[
{
"Name":"myglobal1"
"Expression":"myInput.hello"
}
],
"Rules":[
{
"RuleName": "checkGlobalAndLocalEqualsHello",
"LocalParams":[
{
"Name": "mylocal1",
"Expression": "myglobal1.ToLower()"
}
]
},
定义验证成功、失败行为
可以为每个 Rule 定义验证成功和失败后执行一些代码。
格式示例:
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "input1.TotalBilled * 0.8"
}
},
"OnFailure": {
"Name": "EvaluateRule",
"Context": {
"WorkflowName": "inputWorkflow",
"ruleName": "GiveDiscount10Percent"
}
}
}
OutputExpression
里面定义了执行代码:
"Name": "OutputExpression",
"Context": {
"Expression": "input1.TotalBilled * 0.8"
}
EvaluateRule
定义了执行另一个 Workflow 的 Rule,
"Name": "EvaluateRule",
"Context": {
"WorkflowName": "inputWorkflow",
"ruleName": "GiveDiscount10Percent"
}
在 OnSuccess
、OnFailure
里面,内部结构如下所示:
"Name": "OutputExpression", //Name of action you want to call
"Context": { //This is passed to the action as action context
"Expression": "input1.TotalBilled * 0.8"
}
"Name": "EvaluateRule",
"Context": {
"WorkflowName": "inputWorkflow",
"ruleName": "GiveDiscount10Percent"
}
Name:{xxx}
中的 {xxx}
是一个具体的执行器名称,不是随便定义的,OutputExpression
、EvaluateRule
都是自带的执行器,所谓的执行器就是一个 Func<ActionBase>
,在后面的 自定义执行器 中,可以了解更多。
Context
里面的内容,是一个字典,这些 Key/Value
会被当做参数传递给执行器,每个执行器要求设置的 Context 是不一样的。
另外每个 Rule 都可以定义以下三个字段:
"SuccessEvent": "10",
"ErrorMessage": "One or more adjust rules failed.",
"ErrorType": "Error",
ErrorType
有两个选项,Warn
、Error
,如果这个 Rule 的表达式错误,那么是否弹出异常。如果设置为 Warn
, Rule 有问题,验证结果则会是 false,而不会报异常;如果是 Error
,那么这个 Rule 会中止 Workflow 的执行,程序会报错。
SuccessEvent
跟 ErrorMessage
对应,只是成功、失败的提示消息。
计算折扣
前面提到的都是验证规则,接下来我们将会使用 RulesEngine 实现规则计算。
这里规定,基础折扣为 1.0,如果用户小于 18 岁,打 9 折,如果用户是 VIP,打 9 折,两个规则独立。
如果是小于 18岁,则 1.0 * 0.9
如果是 VIP, 则 1.0 * 0.9
定义一个模型类,用于传递折扣基值。
// 折扣
public class Discount
{
public double Value
{
get; set;
}
}
定义三个参数:
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 16,
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = true
});
var rp3 = new RuleParameter("discount", new Discount
{
Value = 1.0
});
定义规则计算,每个规则计算的是自己的折扣:
[{
"WorkflowName": "Test",
"GlobalParams": [{
"Name": "value",
"Expression": "discount.Value"
}],
"Rules": [{
"RuleName": "CheckAge",
"Expression": "buyer.age < 18",
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "value * 0.9"
}
}
}
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true",
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "value * 0.9"
}
}
}
}
]
}]
完整代码:
static async Task Main()
{
// 定义
var rulesStr = ... ... // JSON
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 16,
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = true
});
var rp3 = new RuleParameter("discount", new Discount
{
Value = 1.0
});
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", rp1, rp2, rp3);
var discount = 1.0;
foreach (var item in resultList)
{
if (item.ActionResult != null && item.ActionResult.Output != null)
{
Console.WriteLine($"{item.Rule.RuleName} 折扣优惠:{item.ActionResult.Output}");
discount = discount * (double)item.ActionResult.Output;
}
}
Console.WriteLine($"最终折扣:{discount}");
}
笔者这里的示例是,每个规则只计算自己的折扣,也就是每个 Rule 都是独立的,下一个 Rule 不会在上一个 Rule 结果上计算。
< 18 : 0.9
VIP : 0.9
如果是折扣可以叠加,那么就是 0.9*0.9
,最终可以拿到 0.81
的折扣。
如果折扣不能叠加,只能选择最佳的优惠,那么就是 0.9
。
使用自定义函数
自定义函数有两种静态函数和实例函数两种,我们可以在 Expression
中调用预先写好的函数。
下面讲解如何在 Rule 中调用自定义的函数。
静态函数
自定义静态函数:
public static bool CheckAge(int age)
{
return age >= 18;
}
注册类型:
ReSettings reSettings = new ReSettings
{
CustomTypes = new[] { typeof(Program) }
};
var bre = new RulesEngine.RulesEngine(Workflows: workflows.ToArray(), reSettings: reSettings);
使用静态函数:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAge",
"Expression": "Program.CheckAge(buyer.Age) == true"
}]
}]
完整代码:
static async Task Main()
{
// 定义
var rulesStr = "[{\"WorkflowName\":\"Test\",\"Rules\":[{\"RuleName\":\"CheckAge\",\"Expression\":\"Program.CheckAge(buyer.Age) == true\"}]}]";
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
ReSettings reSettings = new ReSettings
{
CustomTypes = new[] { typeof(Program) }
};
var bre = new RulesEngine.RulesEngine(Workflows: workflows.ToArray(), reSettings: reSettings);
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Age = 16
});
foreach (var item in resultList)
{
Console.WriteLine("规则名称:{0}, 验证结果:{1}", item.Rule.RuleName, item.IsSuccess);
}
}
public static bool CheckAge(int age)
{
return age >= 18;
}
实例函数
定义实例函数:
public bool CheckAge(int age)
{
return age >= 18;
}
通过 RuleParameter
参数的方式,传递实例:
var rp1 = new RuleParameter("p", new Program());
通过参数的名称调用函数:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAge",
"Expression": "p.CheckAge(buyer.Age) == true"
}]
}]
完整代码:
static async Task Main()
{
// 定义
var rulesStr = "[{\"WorkflowName\":\"Test\",\"Rules\":[{\"RuleName\":\"CheckAge\",\"Expression\":\"p.CheckAge(buyer.Age) == true\"}]}]";
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var rp1 = new RuleParameter("p", new Program());
var bre = new RulesEngine.RulesEngine(Workflows: workflows.ToArray());
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Age = 16
}, rp1);
foreach (var item in resultList)
{
Console.WriteLine("规则名称:{0}, 验证结果:{1}", item.Rule.RuleName, item.IsSuccess);
}
}
public bool CheckAge(int age)
{
return age >= 18;
}
自定义执行器
自定义执行器就是 OnSuccess
、OnFailure
这部分的自定义执行代码,相比静态函数、实例函数,使用自定义执行器,可以获取 Rule 的一些数据。
"Actions": {
"OnSuccess": {
"Name": "MyCustomAction",
"Context": {
"customContextInput": "0.9"
}
}
}
自定义一个执行器,执行器需要继承 ActionBase
。
public class MyCustomAction : ActionBase
{
public override async ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
{
var customInput = context.GetContext<string>("customContextInput");
return await ValueTask.FromResult(new object());
}
}
定义 ReSettings,并在构建规则引擎时,传递进去:
var b = new Buyer
{
Age = 16
};
var reSettings = new ReSettings
{
CustomActions = new Dictionary<string, Func<ActionBase>>
{
{"MyCustomAction", () => new MyCustomAction() }
}
};
var bre = new RulesEngine.RulesEngine(workflows.ToArray(), reSettings);
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", b);
定义 JSON 规则:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAge",
"Expression": "Age <= 18 ",
"Actions": {
"OnSuccess": {
"Name": "MyCustomAction",
"Context": {
"customContextInput": "0.9"
}
}
}
}]
}]
C# RulesEngine 规则引擎:从入门到看懵的更多相关文章
- 【Drools-开源业务规则引擎】入门实例(含源码)
该实例转自:http://blog.csdn.net/quzishen/article/details/6163012 便于理解的应用实例1: 现在我们模拟一个应用场景:网站伴随业务产生而进行的积分发 ...
- Node.js躬行记(15)——活动规则引擎
在日常的业务开发中,会包含许多的业务规则,一般就是用if-else硬编码的方式实现,这样就会增加逻辑的维护成本,若无注释,可能都无法理解规则意图. 因为一旦规则有所改变,那么就需要修改代码再发布代码, ...
- .NET RulesEngine(规则引擎)
一次偶然的机会,让我拿出RulesEngine去完成一个业务,对于业务来说主要是完成一个可伸缩性(不确定的类型,以及不确定的条件,条件的变动可能是持续增加修改的)的业务判断.比如说完成一个成就系统,管 ...
- Drools 规则引擎应用 看这一篇就够了
1 .场景 1.1需求 商城系统消费赠送积分 100元以下, 不加分 100元-500元 加100分 500元-1000元 加500分 1000元 以上 加1000分 ...... 1.2传统做法 1 ...
- Drools规则引擎入门指南(一)
最近项目需要增加风控系统,在经过一番调研以后决定使用Drools规则引擎.因为项目是基于SpringCloud的架构,所以此次学习使用了SpringBoot2.0版本结合Drools7.14.0.Fi ...
- 规则引擎以及blaze 规则库的集成初探之三——Blaze规则引擎和SRL
原文地址:http://jefferson.iteye.com/blog/68604 在上面介绍利用JSR94的api使用的章节中,我们使用的具体引擎的实现是一个商业产品,如果想了解Drools的使用 ...
- jboss规则引擎KIE Drools 6.3.0 Final 教程(1)
前言 目前世面上中文的KIE DROOLS Workbench(JBOSS BRMS)的教程几乎没有,有的也只有灵灵碎碎的使用机器来翻译的(翻的不知所云)或者是基于老版本的JBOSS Guvnor即5 ...
- [z]规则引擎
https://www.ibm.com/developerworks/cn/java/j-drools/ 使用声明性编程方法编写程序的业务逻辑 使用规则引擎可以通过降低实现复杂业务逻辑的组件的复杂性, ...
- 规则引擎之easyRules
规则引擎听起来是蛮高深的一个词语,但透过现象看本质,Martin Fowler 有如下言: You can build a simple rules engine yourself. All you ...
随机推荐
- Luogu2783 有机化学之神偶尔会做作弊 (树链剖分,缩点)
当联通块size<=2时不管 #include <iostream> #include <cstdio> #include <cstring> #includ ...
- CSS 子节点继承父节点(祖先节点)的样式
CSS 有些属性可以让子节点从父节点或祖先节点继承,文本.字体.列表属性等样式都可以被子节点继承.子节点没有自身的样式,子节点将继承父节点或祖先节点的样式. <ul class="co ...
- C++大数据的读写
当一个文件1G以上的这种,使用内存文件映射会提高读写效率: 下边时段出自<windows核心编程>,读取一个大文件,然后统计里边字符出现次数的函数: __int64 CountOs(voi ...
- Vue 3-150行代码实现新国标红绿灯效果案例
昨天刷视频,都是关于新国标红绿灯的,看大家议论纷纷,下班就用150行代码通过Vue组件实践红绿模拟演示,视频也跟大家展示过了.今天接着更新图文版本,大家跟着优雅哥通过该案例实操模拟一下. 不过新国标红 ...
- 【2022知乎爬虫】我用Python爬虫爬了2300多条知乎评论!
您好,我是 @马哥python说,一枚10年程序猿. 一.爬取目标 前些天我分享过一篇微博的爬虫: https://www.cnblogs.com/mashukui/p/16414027.html 但 ...
- PerfView专题 (第十一篇):使用 Diff 功能洞察 C# 内存泄漏增量
一:背景 去年 GC架构师 Maoni 在 (2021 .NET 开发者大会) [https://ke.segmentfault.com/course/1650000041122988/section ...
- grep使用常用操作十五条
grep的全部使用语法参照grep --help,日常工作常用的语法如下:构造数据如下:test001.txt与test002.txt 一.在单个文件中查询指定字符串 grep abc test01/ ...
- 第九十五篇:vue-router的导航守卫
好家伙,考完期末考了. 恢复博客更新 1.什么是导航守卫? "导航"表示路由正在发生变化 设置导航,就在切换过程中进行限制 "守卫"就好理解了 盯着你,不然 ...
- Springboot log4j2总结
Log4j2 PS: Log4j 已不再维护,而最新的是Log4j2, Log4j2 是全部重写了Log4j,并拥有更加优秀的性能 1. 引入依赖,和去掉logging的依赖 <dependen ...
- Odoo自建应用初步总结(一)
学习了<Odoo快速入门与实践 Python开发ERP指南>(刘金亮 2019年5月第1版 机械工业出版社)第6章自建应用入门后进行一下总结. 因为本书作者使用Odoo11,而目前最新版本 ...