zoukankan      html  css  js  c++  java
  • Springboot Drools kie 规则重新加载

    前言

    目前世面上中文的KIE DROOLS Workbench(JBOSS BRMS)的教程几乎没有,有的也只有灵灵碎碎的使用机器来翻译的(翻的不知所云)或者是基于老版本的JBOSS Guvnor即5.x的一些教程,而且这些教程都是”缺胳膊少腿“的,初学者看后不知道它到底在干吗?能干吗?能够解决自己系统中什么问题。

    什么是规则引擎

    规则是让业务人士驱动整个企业过程的最佳实践

    业务规则在实现上的矛盾

    业务规则技术

    引入业务规则技术的目的

    对系统的使用人员

    • 把业务策略(规则)的创建、修改和维护的权利交给业务经理
    • 提高业务灵活性
    • 加强业务处理的透明度,业务规则可以被管理
    • 减少对IT人员的依赖程度
    • 避免将来升级的风险

    对IT开发人员

    • 简化系统架构,优化应用
    • 提高系统的可维护性和维护成本
    • 方便系统的整合
    • 减少编写“硬代码”业务规则的成本和风险

    何为规则引擎

    • 可以将一个或多个的事实映射到一个或多个规则上
    • 接受数据输入,解释业务规则,并根据业务规则做出业务决策

    一个简单的例子

    从IT技术人员的角度看为什么使用规则引擎?

    • 从应用逻辑和数据中将业务逻辑分离
    • 简单! -规则有一个非常简单的结构
    • 让业务用户开发和维护规则以降低成本
    • 声明式编程
    • 性能和可伸缩性
    • 解决复杂的和复合的问题,其中有大量细粒度的规则和事实互动

    DEMO-人寿新卓越变额万能寿险投保规则

    DEMO-人寿新卓越变额万能寿险投保规则的IT实现

    免体检累积最高限额表在规则引擎中的实现:

    什么叫BRMS

    什么是BRMS-考虑两个问题(IT管理者角度)

    什么是BRMS-考虑两个问题(开发人员易用性角度)

    BRMS-Business Rules Management System

    一个优秀的BRMS应该具有的特点

    回溯BRMS开发教程中的那张“业务变现加速器”架构图,考虑下面的问题

    • 业务开发人员开发规则
    • IT人员提供FACT
    • 关键在于“全动态”
    • SQL语句改了怎么办?不重启
    • DAO层改了怎么办?不重启
    • Mybatis的配置文件改了怎么办?不重启

    Drool上生产需要具备的条件

    BRMS中两个重要的概念:因子、公式

    从业务的角度看因子与公式间的关系

    从IT的角度看因子与公式间的关系

    基于BRMS的系统逻辑架构

    这个逻辑图有点复杂,很多人看了都会感觉“不知所云”,OK,不急!我们在后文中会来“回溯”它。

    JBOSS Drools & Guvnor

    世面上成熟的规则引擎有很多,著名的如:IBM 的iLog,pegga rulz(飞马),我们在这边要介绍的也是开源中最著名的jboss rulz。

    Jboss Rulz最早是只有基于.drools的规则文件的一个内嵌式规则引擎,后来它发展成了“规则管理系统”即BRMS,它的BRMS被称为Guvnor。后来在JBOSS Guvnor5.x后它又改名叫"KIE Drools WorkBench“。

    目前世面上中文的KIE DROOLS Workbench(JBOSS BRMS)的教程几乎没有,有的也只有灵灵碎碎的使用机器来翻译的(翻的不知所云)或者是基于老板的JBOSS Guvnor即5.x的一些教程,而且这些教程都是”缺胳膊少腿“的,初学者看后不知道它到底在干吗?能干吗?能够解决自己系统中什么问题。

    Guvnor核心功能-最好的开源规则引擎

    如下介绍Drools使用:

    1.template
    drools6开始提供模板的概念;
    
    模板能为我们提供简单的规则替换;做到简单的规则动态加载;
    
    本例子的demo基于最新稳定版drools6.4
    
    2.项目结构
    
    3.pom依赖
    <!--drools-->
    <dependency>
        <groupId>org.drools</groupId>
        <artifactId>drools-core</artifactId>
        <version>6.4.0.Final</version>
    </dependency>
    <dependency>
        <groupId>org.drools</groupId>
        <artifactId>drools-decisiontables</artifactId>
        <version>6.4.0.Final</version>
    </dependency>
    4.代码
    Message.java
    package com.caicongyang.drools.templates;
     
    import java.io.Serializable;
     
    /**
     * @author caicongyang1
     * @version id: Message, v 0.1 16/9/29 下午3:06 caicongyang1 Exp $$
     */
    public class Message implements Serializable {
     
        private static final long serialVersionUID = -3168739008574463191L;
     
        public static final int HELLO = 0;
        public static final int GOODBYE = 1;
     
        private String message;
     
        private int status;
     
        public String getMessage() {
            return this.message;
        }
     
        public void setMessage(String message) {
            this.message = message;
        }
     
        public int getStatus() {
            return this.status;
        }
     
        public void setStatus(int status) {
            this.status = status;
        }
     
    }
    
    MyDataProvider.java
    package com.caicongyang.drools.templates;
     
    import java.util.Iterator;
    import java.util.List;
     
    import org.drools.template.DataProvider;
     
    /**
     * @author caicongyang1
     * @version id: Message, v 0.1 16/9/29 下午3:06 caicongyang1 Exp $$
     */
    public class MyDataProvider implements DataProvider {
     
        private Iterator<String[]> iterator;
     
        public MyDataProvider(List<String[]> rows) {
            this.iterator = rows.iterator();
        }
     
        public boolean hasNext() {
            return iterator.hasNext();
        }
     
        public String[] next() {
            return iterator.next();
        }
    }
    
    DataProviderCompilerTest.java
    package com.caicongyang.drools.templates;
     
    import java.util.ArrayList;
     
    import org.drools.template.DataProviderCompiler;
    import org.kie.api.KieServices;
    import org.kie.api.builder.KieBuilder;
    import org.kie.api.builder.KieFileSystem;
    import org.kie.api.builder.Message.Level;
    import org.kie.api.builder.model.KieBaseModel;
    import org.kie.api.builder.model.KieModuleModel;
    import org.kie.api.io.KieResources;
    import org.kie.api.runtime.KieContainer;
    import org.kie.api.runtime.KieSession;
     
    /**
     *
     * Drools模板实例应用
     *
     * @author caicongyang1
     * @version id: Message, v 0.1 16/9/29 下午3:06 caicongyang1 Exp $$
     */
    public class DataProviderCompilerTest {
     
        public static void main(String[] args) {
            ArrayList<String[]> rows = new ArrayList<String[]>();
            rows.add(new String[] { "1", "status == 0" });
     
            MyDataProvider tdp = new MyDataProvider(rows);
            DataProviderCompiler converter = new DataProviderCompiler();
            String drl = converter.compile(tdp, "/rules/rule_template_1.drl");
     
            System.out.println(drl);
     
            KieServices kieServices = KieServices.Factory.get();
            KieResources resources = kieServices.getResources();
            KieModuleModel kieModuleModel = kieServices.newKieModuleModel();//1
     
            KieBaseModel baseModel = kieModuleModel.newKieBaseModel("FileSystemKBase").addPackage("rules");//2
            baseModel.newKieSessionModel("FileSystemKSession");//3
            KieFileSystem fileSystem = kieServices.newKieFileSystem();
     
            String xml = kieModuleModel.toXML();
            System.out.println(xml);//4
            fileSystem.writeKModuleXML(xml);//5
     
            String path = DataProviderCompilerTest.class.getClass().getResource("/").getPath();
            fileSystem.write("src/main/resources/rules/rule1.drl", drl);
     
            KieBuilder kb = kieServices.newKieBuilder(fileSystem);
            kb.buildAll();//7
            if (kb.getResults().hasMessages(Level.ERROR)) {
                throw new RuntimeException("Build Errors:
    " + kb.getResults().toString());
            }
            KieContainer kContainer = kieServices.newKieContainer(kieServices.getRepository().getDefaultReleaseId());
     
            KieSession kSession = kContainer.newKieSession("FileSystemKSession");
     
            Message message = new Message();
            message.setMessage("Hello World");
            message.setStatus(Message.HELLO);
     
            kSession.insert(message);
            kSession.fireAllRules();
            kSession.dispose();
     
        }
     
    }
    
    rule_template_1.dl
    template header
    RULE_ID
    RULE_KEY1
     
    package com.caicongyang.drools.templates;
     
    import com.caicongyang.drools.templates.Message;
     
    template "RULE"
    rule "RULE_@{RULE_ID}"
        when
            m: Message(@{RULE_KEY1})
        then
            System.out.println(m.getMessage());
    end
    end template
    
    应用层->
    
    DAOProxyFactory factory = new DAOProxyFactory();
    StudentService aopService = (StudentService) factory.createProxyInstance(new StudentServiceImpl());
    int age = aopService.getAge("ymk");
    
    
    
    
    Service层
    
    public int getAge(String userId) throws Exception {
    	UserInfoMapper userMapper = batisSession.getMapper(UserInfoMapper.class);
    	int age = userMapper.selelctUser(userId);
    	return age;
    }
    
    
    Dao层->myBatis哪有Dao层
    
    
    
    mybatis-conf.xml文件内容
    
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
    <configuration>
    <environments default="development">
    <environment id="development">
    <transactionManager type="JDBC"></transactionManager>
    <dataSource type="org.sky.drools.sql.datasource.DruidDataSourceFactory">
    <property name="driver" value="com.mysql.jdbc.Driver" />
    <property name="url"
    value="jdbc:mysql://192.168.0.101:3306/mk?useUnicode=true&characterEncoding=UTF-8" />
    <property name="username" value="mk" />
    <property name="password" value="aaaaaa" />
    <property name="maxActive" value="20" />
    <property name="initialSize" value="5" />
    <property name="minIdle" value="1" />
    <property name="testWhileIdle" value="true" />
    <property name="validationQuery" value="SELECT 1" />
    <property name="testOnBorrow" value="true" />
    <property name="testOnReturn" value="true" />
    </dataSource>
    </environment>
    </environments>
    <mappers>
    <mapper resource="mybatis/mapper/UserInfoMapper.xml" />
    <mapper resource="mybatis/mapper/ApplicantListMapper.xml" />
    </mappers>
    </configuration>
    
    我们在此使用了阿里的Druid连接池
    我们可以直接在此文件中写上值而不是“替换符”,因为对于drools上传一个jar包是无需重启服务的,上传完毕该jar后,KIE WB内部即发生了更改。
    
    Mybatis的具体mapper文件-这个太简单了吧,给一个例子吧:
    
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="org.sky.drools.dao.mapper.UserInfoMapper">
    <select id="selelctUser" parameterType="String" resultType="int">
    SELECT age FROM user_info WHERE user_id=#{userId}
    </select>
    </mapper>
    
    把业务、因子做成全动态
    
    现在我们来书写我们的FACT
    
    public class UserInfoBean implements Serializable 
    private int age = 0;
    private String applicant;
    private String userId;
    private boolean validFlag = false;
    }
     
    没什么好多说的,我们为这个Bean提供了一组field,同时我们会为每个私有成员生成一对set/get方法。
     
    关键在于getAge()方法的覆盖:
     
    public int getAge() throws Exception {
    int age = 0;
     
    try {
    DAOProxyFactory factory = new DAOProxyFactory();
    UserInfoService stdService = (UserInfoService) factory.createProxyInstance(new UserInfoServiceImpl());
    age = stdService.getAge(userId);
    System.out.println(age);
    } catch (Exception e) {
    	System.err.println(e.getMessage());
    	throw new Exception("mybatis error: " + e.getMessage(), e);
    }
    return age;
    }
    
    我们提供一个Service->UserInfoService
    
    
    
    
    UserInfoServiceImpl具体内容
    
    package org.sky.drools.service;
     
    import org.apache.ibatis.session.SqlSession;
    import org.sky.drools.dao.mapper.ApplicantListMapper;
    import org.sky.drools.dao.mapper.UserInfoMapper;
    import org.sky.drools.sql.datasource.IsSession;
     
    public class UserInfoServiceImpl implements UserInfoService {
     
    	@IsSession
    	private SqlSession batisSession = null;
     
    	public void setBatisSession(SqlSession batisSession) {
    		this.batisSession = batisSession;
    	}
     
    	public int getAge(String userId) throws Exception {
    		UserInfoMapper userMapper = batisSession.getMapper(UserInfoMapper.class);
    		int age = userMapper.selelctUser(userId);
    		return age;
    	}
     
    	public int existInList(String userId) throws Exception {
    		ApplicantListMapper listMapper = batisSession.getMapper(ApplicantListMapper.class);
    		int result = listMapper.selelctUserInApplicant(userId);
    		return result;
    	}
     
    }
    
    可以看到这边对batisSession不做任何的动作,只是像Spring的IOC中的一个“注入”一样来使用。
    把因子打包上传至规则引擎
    
    增加规则:
    
    package org.sky.drools.dbrulz;
     
    no-loop
     
     
    declare User
        age : int;
        validFlag : boolean;
    end
     
    rule "init studentbean"
    salience 1000
        when
            u : UserInfoBean()
        then
          User user=new User();
          user.setAge(u.getAge());
          System.out.println("valid applicant for["+u.getUserId()+"] and validFlag is["+u.isValidFlag()+"]");
          insert(user);
    end
     
    rule "less than < 17"
     
        when
           u : User ( age <17); 
           facts : UserInfoBean()
        then
            facts.setApplicant("0");
    end
     
    rule "less than < 45"
     
        when
           u : User  ( age <46 && age >=17); 
           facts : UserInfoBean()
        then
            facts.setApplicant("7000000");
    end
     
    rule "less than < 70"
     
        when
            u : User  ( age <70 && age >=46); 
           facts : UserInfoBean()
        then
            facts.setApplicant("5000000");
    end
    
    现在因为有了KIE SERVER,于是我们直接在此规则上多加一步
    在规则开始处修改如下:
    
    
    
    declare User
        age : int;
        validFlag : boolean;
    end
     
    rule "init studentbean"
    salience 1000
        when
            $u:UserInfoBean(userId!=null)
            //$u:UserInfoBean();
        then
          System.out.println("valid applicant for["+$u.getUserId()+"] and validFlag is["+$u.isValidFlag()+"]");
          User userVO=new User();
          userVO.setAge( $u.getAge());
          userVO.setValidFlag($u.isValidFlag());
          insert(userVO);
          
    End
    
    
    以下这段加在原有规则最后:
    
    
    rule "not a valid applicant"
        when
           user:User(!validFlag) && u:UserInfoBean(userId!=null)
           //u : UserInfoBean( !validFlag ); 
        then
            u.setApplicant("0");
    end
    
    package org.sky.drools.dbrulz;
    declare User
        age : int;
        validFlag : boolean;
    end
    rule "init studentbean"
    salience 1000
        when
            $u:UserInfoBean(userId!=null)
    then
          System.out.println("valid applicant for["+$u.getUserId()+"] and validFlag is["+$u.isValidFlag()+"]");
          User userVO=new User();
          userVO.setAge( $u.getAge());
          userVO.setValidFlag($u.isValidFlag());
          insert(userVO);
    end
    rule "less than < 17"
     
        when
           user: User(age<17) && u:UserInfoBean(userId!=null)
        then
           u.setApplicant("0");
    end
    rule "less than < 45"
        when
           (user: User(age<46 && age>=17)) && u:UserInfoBean(userId!=null)
    then
            System.out.println("set applicant for "+u.getUserId()+" to 7000000");
            u.setApplicant("7000000");
    end
    rule "less than < 70"
        when
           (user:User(age<70 && age>=46)) && u:UserInfoBean(userId!=null)
    then
            System.out.println("set applicant for "+u.getUserId()+" to 5000000");
            u.setApplicant("5000000");
    end
     
    rule "not a valid applicant"
        when
           user:User(!validFlag) && u:UserInfoBean(userId!=null)
    then
            u.setApplicant("0");
    end
    
    规则解释:
    
    
    其实这个规则很简单,关键之处在于我们更改了Fact中的getAge(),那么它会在每一个规则处进行一次数据库操作。
    
    
    虽然,我们的数据库操作用的是myBatis,在取得SessionFactory和Session时都已经做成了POOL和SINGLETON且ThreadSafe了。
    
    
    但是每个getAge()它还是会作一次“迷你AOP”和一次数据库的DAO操作,对不对?
    
    
    那么我们来说,我们取得一个输入人的UserId,拿到了它的年龄即可以用规则来计算它的保额了,所以我们说:通过userId取getAge()仅应该做一次数据库操作,对不对?
    
    
    因此我们这边使用了Drools的declare语法,预声明了一个Object,该Object会运行在一条:
    
    
    rule “init studentbean”且salience 1000的规则中。
    
    
    这条规则中的salience 1000代表(salience后的数字越高运行优先级最高-即默认“主方法”),在这条规则中我们作了一件 事:
    
    通过用户转入的fact的userId然后调用getAge()进行一次数据库操作并赋给declare的User,以后就用这个User进行全局规则匹配即可。

    《Drools7.0.0.Final规则引擎教程》之Springboot集成中介绍了怎样将Drools与Springboot进行集成,本篇博客介绍一下集成之后,如何实现从数据库读取规则并重新加载规则的简单demo。因本章重点介绍的是Drools相关操作的API,所以将查询数据库部分的操作省略,直接使用数据库查询出的规则代码来进行规则的重新加载。另外,此示例采用访问一个http请求来进行重新加载,根据实际需要可考虑定时任务进行加载等扩展方式。最终的使用还是要结合具体业务场景来进行分析扩展。

    整体项目结构

    这里写图片描述 
    上图是基于intellij maven的项目结构。其中涉及到springboot的Drools集成配置类,重新加载规则类。一些实体类和工具类。下面会抽出比较重要的类进行分析说明。

    DroolsAutoConfiguration

    @Configuration
    public class DroolsAutoConfiguration {
    
        private static final String RULES_PATH = "rules/";
    
        @Bean
        @ConditionalOnMissingBean(KieFileSystem.class)
        public KieFileSystem kieFileSystem() throws IOException {
            KieFileSystem kieFileSystem = getKieServices().newKieFileSystem();
            for (Resource file : getRuleFiles()) {
                kieFileSystem.write(ResourceFactory.newClassPathResource(RULES_PATH + file.getFilename(), "UTF-8"));
            }
            return kieFileSystem;
        }
    
        private Resource[] getRuleFiles() throws IOException {
            ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver();
            return resourcePatternResolver.getResources("classpath*:" + RULES_PATH + "**/*.*");
        }
    
        @Bean
        @ConditionalOnMissingBean(KieContainer.class)
        public KieContainer kieContainer() throws IOException {
            final KieRepository kieRepository = getKieServices().getRepository();
    
            kieRepository.addKieModule(new KieModule() {
                public ReleaseId getReleaseId() {
                    return kieRepository.getDefaultReleaseId();
                }
            });
    
            KieBuilder kieBuilder = getKieServices().newKieBuilder(kieFileSystem());
            kieBuilder.buildAll();
    
            KieContainer kieContainer = getKieServices().newKieContainer(kieRepository.getDefaultReleaseId());
            KieUtils.setKieContainer(kieContainer);
            return getKieServices().newKieContainer(kieRepository.getDefaultReleaseId());
        }
    
        private KieServices getKieServices() {
            return KieServices.Factory.get();
        }
    
        @Bean
        @ConditionalOnMissingBean(KieBase.class)
        public KieBase kieBase() throws IOException {
            return kieContainer().getKieBase();
        }
    
        @Bean
        @ConditionalOnMissingBean(KieSession.class)
        public KieSession kieSession() throws IOException {
            KieSession kieSession = kieContainer().newKieSession();
            KieUtils.setKieSession(kieSession);
            return kieSession;
        }
    
        @Bean
        @ConditionalOnMissingBean(KModuleBeanFactoryPostProcessor.class)
        public KModuleBeanFactoryPostProcessor kiePostProcessor() {
            return new KModuleBeanFactoryPostProcessor();
        }
    }

    此类主要用来初始化Drools的配置,其中需要注意的是对KieContainer和KieSession的初始化之后都将其设置到KieUtils类中。

    KieUtils

    KieUtils类中存储了对应的静态方法和静态属性,供其他使用的地方获取和更新。

    public class KieUtils {
    
        private static KieContainer kieContainer;
    
        private static KieSession kieSession;
    
        public static KieContainer getKieContainer() {
            return kieContainer;
        }
    
        public static void setKieContainer(KieContainer kieContainer) {
            KieUtils.kieContainer = kieContainer;
            kieSession = kieContainer.newKieSession();
        }
    
        public static KieSession getKieSession() {
            return kieSession;
        }
    
        public static void setKieSession(KieSession kieSession) {
            KieUtils.kieSession = kieSession;
        }
    }

    ReloadDroolsRules

    提供了reload规则的方法,也是本篇博客的重点之一,其中从数据库读取的规则代码直接用字符串代替,读者可自行进行替换为数据库操作。

    @Service
    public class ReloadDroolsRules {
    
        public void reload() throws UnsupportedEncodingException {
            KieServices kieServices = KieServices.Factory.get();
            KieFileSystem kfs = kieServices.newKieFileSystem();
            kfs.write("src/main/resources/rules/temp.drl", loadRules());
            KieBuilder kieBuilder = kieServices.newKieBuilder(kfs).buildAll();
            Results results = kieBuilder.getResults();
            if (results.hasMessages(Message.Level.ERROR)) {
                System.out.println(results.getMessages());
                throw new IllegalStateException("### errors ###");
            }
    
            KieUtils.setKieContainer(kieServices.newKieContainer(getKieServices().getRepository().getDefaultReleaseId()));
            System.out.println("新规则重载成功");
        }
    
        private String loadRules() {
            // 从数据库加载的规则
            return "package plausibcheck.adress
    
     import com.neo.drools.model.Address;
     import com.neo.drools.model.fact.AddressCheckResult;
    
     rule "Postcode 6 numbers"
    
        when
      then
            System.out.println("规则2中打印日志:校验通过!");
     end";
    
        }
    
        private KieServices getKieServices() {
            return KieServices.Factory.get();
        }
    
    }
    

    TestController

    提供了验证入口和reload入口。

    @RequestMapping("/test")
    @Controller
    public class TestController {
    
    
        @Resource
        private ReloadDroolsRules rules;
    
        @ResponseBody
        @RequestMapping("/address")
        public void test(){
            KieSession kieSession = KieUtils.getKieSession();
    
            Address address = new Address();
            address.setPostcode("994251");
    
            AddressCheckResult result = new AddressCheckResult();
            kieSession.insert(address);
            kieSession.insert(result);
            int ruleFiredCount = kieSession.fireAllRules();
            System.out.println("触发了" + ruleFiredCount + "条规则");
    
            if(result.isPostCodeResult()){
                System.out.println("规则校验通过");
            }
    
        }
    
    
        @ResponseBody
        @RequestMapping("/reload")
        public String reload() throws IOException {
            rules.reload();
            return "ok";
        }
    }
    

    其他

    其他类和文件内容参考springboot集成部分或demo源代码。

    操作步骤如下:启动项目访问http://localhost:8080/test/address 会首先触发默认加载的address.drl中的规则。当调用reload之后,再次调用次方法会发现触发的规则已经变成重新加载的规则了。

    CSDN demo下载地址:http://download.csdn.net/detail/wo541075754/9918297 
    GitHub demo下载地址:https://github.com/secbr/drools/tree/master/springboot-drools-reload-rules

    点击链接关注《Drools博客专栏》

    正因为当初对未来做了太多的憧憬,所以对现在的自己尤其失望。生命中曾经有过的所有灿烂,终究都需要用寂寞来偿还。
  • 相关阅读:
    【cocos2d-x + Lua(2) C++和lua数据通讯之间的互调】
    【cocos2d-x + Lua(1) 绑定Lua并使用tolua++】
    【cocos2d-x 手游研发小技巧(6)聊天系统+字体高亮】
    【cocos2d-x 仙凡奇缘-网游研发(2) 角色换线系统】
    【cocos2d-x 仙凡奇缘-网游研发(1) 登录&注册】
    python数据分析---第04章 NumPy基础:数组和矢量计算
    python面向对象(一)
    python 常用模块之random,os,sys 模块
    (python数据分析)第03章 Python的数据结构、函数和文件
    python常用模块之时间模块
  • 原文地址:https://www.cnblogs.com/candlia/p/11920105.html
Copyright © 2011-2022 走看看