zoukankan      html  css  js  c++  java
  • 菜鸟-手把手教你把Acegi应用到实际项目中(10)-保护业务方法

       前面已经讲过关于保护Web资源的方式,其中包括直接在XML文件中配置和自定义实现FilterInvocationDefinitionSource接口两种方式。在实际企业应用中,保护Web资源显得非常重要,它是保障Web应用安全性的关键部分。有了它,我们的Web应用就显得更加安全了。的确,部分Web应用有了它已经足够了。但许多时候却有这样的场景,某企业的系统允许用户A查看数据,但不允许他修改或删除数据;而用户B不但可以查看数据,而且可以修改和删除数据。此时,前面所说的保护Web资源的方式就无法满足这个需求了。既而我们会想到,关于查看、修改和删除等操作,都是通过操作相应业务方法来实现的。那么,我们可不可以实现对这些业务方法的保护呢?答案是肯定的,Acegi为我们提供了这一实现机制。
          对于业务方法的保护,其实跟保护Web资源的方式非常相似。只要我们弄清楚了保护Web资源的工作原理和各种实现方式,再来学习保护业务方法相关的知识,那么将会很快上手。
          在继续阅读本节内容之前,朋友们应该先阅读“菜鸟-教你把Acegi应用到实际项目(9)-实现FilterInvocationDefinition”一节(http://zhanjia.iteye.com/blog/261123)。因为此篇的第二部分内容与前一篇的内容非常相似,故在此我只列出不同部分,不做详细解释。

    一、在Acegi配置文件中配置实现保护业务方法
    1、下面先看看关于保护Web资源和保护业务方法的部分配置:
    *保护Web资源的配置

    Xml代码 复制代码 收藏代码
    1. <bean id="filterInvocationInterceptor"  
    2.     class="org.acegisecurity.intercept.web.FilterSecurityInterceptor">  
    3.     <property name="authenticationManager" ref="authenticationManager" />  
    4.     <property name="accessDecisionManager"  
    5.         ref="httpRequestAccessDecisionManager" />  
    6.     <property name="objectDefinitionSource">  
    7.         <value>  
    8.             <![CDATA[  
    9.                 CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON  
    10.                 PATTERN_TYPE_APACHE_ANT  
    11.                 /**/*.jpg=AUTH_ANONYMOUS,AUTH_USER  
    12.                 /**/*.gif=AUTH_ANONYMOUS,AUTH_USER  
    13.                 /**/*.png=AUTH_ANONYMOUS,AUTH_USER  
    14.                 /login.jsp*=AUTH_ANONYMOUS,AUTH_USER  
    15.                 /**=AUTH_USER  
    16.             ]]>  
    17.         </value>  
    18.     </property>  
    19. </bean>  
    <bean id="filterInvocationInterceptor"
    	class="org.acegisecurity.intercept.web.FilterSecurityInterceptor">
    	<property name="authenticationManager" ref="authenticationManager" />
    	<property name="accessDecisionManager"
    		ref="httpRequestAccessDecisionManager" />
    	<property name="objectDefinitionSource">
    		<value>
    			<![CDATA[
    				CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON
    			    PATTERN_TYPE_APACHE_ANT
    			    /**/*.jpg=AUTH_ANONYMOUS,AUTH_USER
    			    /**/*.gif=AUTH_ANONYMOUS,AUTH_USER
    			    /**/*.png=AUTH_ANONYMOUS,AUTH_USER
    			    /login.jsp*=AUTH_ANONYMOUS,AUTH_USER
      				/**=AUTH_USER
    			]]>
    		</value>
    	</property>
    </bean>

    *保护业务方法配置

    Xml代码 复制代码 收藏代码
    1. <bean id="contactManagerSecurity"  
    2.     class="org.acegisecurity.intercept.method.aopalliance.MethodSecurityInterceptor">  
    3.     <property name="authenticationManager" ref="authenticationManager" />  
    4.     <property name="accessDecisionManager"  
    5.         ref="httpRequestAccessDecisionManager" />  
    6.     <property name="objectDefinitionSource">  
    7.         <value>  
    8.             sample.service.IContactManager.create=AUTH_FUNC_ContactManager.create   
    9.             sample.service.IContactManager.delete=AUTH_FUNC_ContactManager.delete   
    10.             sample.service.IContactManager.getAll=AUTH_FUNC_ContactManager.getAll   
    11.             sample.service.IContactManager.getById=AUTH_FUNC_ContactManager.getById   
    12.             sample.service.IContactManager.update=AUTH_FUNC_ContactManager.update   
    13.         </value>  
    14.     </property>  
    15. </bean>  
    <bean id="contactManagerSecurity"
    	class="org.acegisecurity.intercept.method.aopalliance.MethodSecurityInterceptor">
    	<property name="authenticationManager" ref="authenticationManager" />
    	<property name="accessDecisionManager"
    		ref="httpRequestAccessDecisionManager" />
    	<property name="objectDefinitionSource">
    		<value>
    			sample.service.IContactManager.create=AUTH_FUNC_ContactManager.create
    			sample.service.IContactManager.delete=AUTH_FUNC_ContactManager.delete
    			sample.service.IContactManager.getAll=AUTH_FUNC_ContactManager.getAll
    			sample.service.IContactManager.getById=AUTH_FUNC_ContactManager.getById
    			sample.service.IContactManager.update=AUTH_FUNC_ContactManager.update
    		</value>
    	</property>
    </bean>
    

          从上面配置方式可以看到,两种配置方式基本差不多,只有两个地方存在差别。一个是实现类不同,前者是FilterSecurityInterceptor,后者是MethodSecurityInterceptor。另外一个是objectDefinitionSource中的配置不同。
          FilterSecurityInterceptor和MethodSecurityInterceptor都继承自AbstractSecurityInterceptor,而且都拥有objectDefinitionSource属性。尽管他们都拥有相同的objectDefinitionSource属性,但前者属于FilterInvocationDefinitionSource类型,而后者属于MethodDefinitionSource类型。然而,这两个Source又都继承自ObjectDefinitionSource。正因为如此,所以保护Web资源和保护业务方法的原理是一样的,只要懂得运用其中一个,那么另一个也就会了。

          “=”号左边的内容代表了方法名全称,即以类的全限定名和目标方法名一同构成。“=”号右边的内容代表了左边方法所对应的角色集合,角色集合由逗号隔开的多个角色名构成。

    2、业务接口和实现类

    Java代码 复制代码 收藏代码
    1. public interface IContactManager{   
    2.   public List getAll();   
    3.   public Contact getById(Integer id);   
    4.   public void create(Contact contact);   
    5.   public void update(Contact contact);   
    6.   public void delete(Contact contact);   
    7. }   
    8.   
    9. public class ContactManager implements IContactManager {   
    10.     ……   
    11. }  
    public interface IContactManager{
      public List getAll();
      public Contact getById(Integer id);
      public void create(Contact contact);
      public void update(Contact contact);
      public void delete(Contact contact);
    }
    
    public class ContactManager implements IContactManager {
    	……
    }

    3、加入保护业务方法的拦截器 

    Xml代码 复制代码 收藏代码
    1. <bean id="transactionInterceptor"  
    2.     class="org.springframework.transaction.interceptor.TransactionInterceptor">  
    3.     <property name="transactionManager">  
    4.         <ref bean="transactionManager" />  
    5.     </property>  
    6.     <property name="transactionAttributeSource">  
    7.         <value>  
    8.             sample.service.impl.ContactManager.*=PROPAGATION_REQUIRED   
    9.         </value>  
    10.     </property>  
    11. </bean>  
    12.   
    13. <!--dao start -->  
    14. <bean id="contactDao" class="sample.dao.impl.ContactDao">  
    15.     <property name="dataSource">  
    16.         <ref bean="dataSource" />  
    17.     </property>  
    18. </bean>  
    19.   
    20. <!--service start -->  
    21. <bean id="contactManagerTarget"  
    22.     class="sample.service.impl.ContactManager">  
    23.     <property name="contactDao">  
    24.         <ref bean="contactDao" />  
    25.     </property>  
    26. </bean>  
    27.   
    28. <bean id="contactManager"  
    29.     class="org.springframework.aop.framework.ProxyFactoryBean">  
    30.     <property name="proxyInterfaces">  
    31.         <value>sample.service.IContactManager</value>  
    32.     </property>  
    33.     <property name="interceptorNames">  
    34.         <list>  
    35.             <idref local="transactionInterceptor" />  
    36.             <STRONG><!-- 加入保护业务方法的拦截器 -->  
    37.             <idref bean="contactManagerSecurity"/></STRONG>  
    38.             <idref local="contactManagerTarget" />  
    39.         </list>  
    40.     </property>  
    41. </bean>  
    <bean id="transactionInterceptor"
    	class="org.springframework.transaction.interceptor.TransactionInterceptor">
    	<property name="transactionManager">
    		<ref bean="transactionManager" />
    	</property>
    	<property name="transactionAttributeSource">
    		<value>
    			sample.service.impl.ContactManager.*=PROPAGATION_REQUIRED
    		</value>
    	</property>
    </bean>
    
    <!--dao start -->
    <bean id="contactDao" class="sample.dao.impl.ContactDao">
    	<property name="dataSource">
    		<ref bean="dataSource" />
    	</property>
    </bean>
    
    <!--service start -->
    <bean id="contactManagerTarget"
    	class="sample.service.impl.ContactManager">
    	<property name="contactDao">
    		<ref bean="contactDao" />
    	</property>
    </bean>
    
    <bean id="contactManager"
    	class="org.springframework.aop.framework.ProxyFactoryBean">
    	<property name="proxyInterfaces">
    		<value>sample.service.IContactManager</value>
    	</property>
    	<property name="interceptorNames">
    		<list>
    			<idref local="transactionInterceptor" />
    			<!-- 加入保护业务方法的拦截器 -->
    			<idref bean="contactManagerSecurity"/>
    			<idref local="contactManagerTarget" />
    		</list>
    	</property>
    </bean>

     二、自定义实现MethodDefinitionSource接口保护业务方法
          这部分内容建立在前一篇的基础之上,请参考:“菜鸟-教你把Acegi应用到实际项目(9)-实现FilterInvocationDefinition”一节(http://zhanjia.iteye.com/blog/261123)

    1、修改RdbmsEntryHolder类
    前篇保护Web资源时RdbmsEntryHolder类如下:

    Java代码 复制代码 收藏代码
    1. public class RdbmsEntryHolder implements Serializable {   
    2.     // 保护的URL模式      
    3.     private String url;      
    4.     // 要求的角色集合      
    5.     private ConfigAttributeDefinition cad;   
    6.     ......   
    7. }  
    public class RdbmsEntryHolder implements Serializable {
        // 保护的URL模式   
        private String url;   
        // 要求的角色集合   
        private ConfigAttributeDefinition cad;
        ......
    }
    

          由于我们现在所要保护的是业务方法,故我们将url变量易名为method,这样会更加明确。method变量存放类似于“sample.service.IContactManager.create”、“sample.service.IContactManager.update*”的方法名全称。

    2、修改RdbmsSecuredUrlDefinition类
          将RdbmsSecuredUrlDefinition改名为RdbmsSecuredMethodDefinition,黑体部分为修改后的代码。

    Java代码 复制代码 收藏代码
    1. public class RdbmsSecuredMethodDefinition extends MappingSqlQuery{   
    2.   
    3.     protected static final Log log = LogFactory.getLog(RdbmsSecuredMethodDefinition.class);   
    4.        
    5.     public RdbmsSecuredMethodDefinition(DataSource ds) {   
    6.         super(ds, Constants.<STRONG>ACEGI_RDBMS_SECURED_SQL</STRONG>);   
    7.         compile();   
    8.     }   
    9.   
    10.     /**  
    11.      * convert each row of the ResultSet into an object of the result type.  
    12.      */  
    13.     protected Object mapRow(ResultSet rs, int rownum)   
    14.         throws SQLException {          
    15.         RdbmsEntryHolder rsh = new RdbmsEntryHolder();   
    16.         <STRONG>// 设置业务方法   
    17.         rsh.setMethod(rs.getString(Constants.ACEGI_RDBMS_SECURED_METHOD).trim());</STRONG>   
    18.            
    19.         ConfigAttributeDefinition cad = new ConfigAttributeDefinition();   
    20.            
    21.         String rolesStr = rs.getString(Constants.<STRONG>ACEGI_RDBMS_SECURED_ROLES</STRONG>).trim();   
    22.         // commaDelimitedListToStringArray:Convert a CSV list into an array of Strings   
    23.         // 以逗号为分割符, 分割字符串   
    24.         String[] tokens =    
    25.                 StringUtils.commaDelimitedListToStringArray(rolesStr); // 角色名数组   
    26.         // 构造角色集合   
    27.         for(int i = 0; i < tokens.length;++i)   
    28.             cad.addConfigAttribute(new SecurityConfig(tokens[i]));   
    29.            
    30.         //设置角色集合   
    31.         rsh.setCad(cad);   
    32.            
    33.         return rsh;   
    34.     }   
    35.   
    36. }  
    public class RdbmsSecuredMethodDefinition extends MappingSqlQuery{
    
    	protected static final Log log = LogFactory.getLog(RdbmsSecuredMethodDefinition.class);
    	
        public RdbmsSecuredMethodDefinition(DataSource ds) {
            super(ds, Constants.ACEGI_RDBMS_SECURED_SQL);
            compile();
        }
    
        /**
         * convert each row of the ResultSet into an object of the result type.
         */
        protected Object mapRow(ResultSet rs, int rownum)
            throws SQLException {    	
        	RdbmsEntryHolder rsh = new RdbmsEntryHolder();
        	// 设置业务方法
        	rsh.setMethod(rs.getString(Constants.ACEGI_RDBMS_SECURED_METHOD).trim());
        	
            ConfigAttributeDefinition cad = new ConfigAttributeDefinition();
            
            String rolesStr = rs.getString(Constants.ACEGI_RDBMS_SECURED_ROLES).trim();
            // commaDelimitedListToStringArray:Convert a CSV list into an array of Strings
            // 以逗号为分割符, 分割字符串
            String[] tokens = 
            		StringUtils.commaDelimitedListToStringArray(rolesStr); // 角色名数组
            // 构造角色集合
            for(int i = 0; i < tokens.length;++i)
            	cad.addConfigAttribute(new SecurityConfig(tokens[i]));
            
            //设置角色集合
            rsh.setCad(cad);
        	
            return rsh;
        }
    
    }
    

     其中,Constants常量类如下:

    Java代码 复制代码 收藏代码
    1. public interface Constants {   
    2.   
    3.     // Acegi相关常量--------------------------------------------   
    4.        
    5.     // 业务方法与对应角色查询语句   
    6.     public static final String ACEGI_RDBMS_SECURED_SQL = "SELECT authority,protected_res FROM authorities WHERE auth_type='FUNCTION' AND authority LIKE 'AUTH_FUNC_ContactManager%'";   
    7.   
    8.     // 方法字段名称   
    9.     public static final String ACEGI_RDBMS_SECURED_METHOD = "protected_res";   
    10.        
    11.     // 角色字符串字段名称   
    12.     public static final String ACEGI_RDBMS_SECURED_ROLES = "authority";   
    13.        
    14. }  
    public interface Constants {
    
    	// Acegi相关常量--------------------------------------------
    	
    	// 业务方法与对应角色查询语句
    	public static final String ACEGI_RDBMS_SECURED_SQL = "SELECT authority,protected_res FROM authorities WHERE auth_type='FUNCTION' AND authority LIKE 'AUTH_FUNC_ContactManager%'";
    
    	// 方法字段名称
    	public static final String ACEGI_RDBMS_SECURED_METHOD = "protected_res";
    	
    	// 角色字符串字段名称
    	public static final String ACEGI_RDBMS_SECURED_ROLES = "authority";
    	
    }
    

     3、自定义实现MethodDefinitionSource接口
          修改RdbmsFilterInvocationDefinitionSource类,改名为RdbmsMethodDefinitionSource,并修改相应方法,黑体部分为修改后的代码。

    变量:

     修改前:private RdbmsSecuredUrlDefinition rdbmsInvocationDefinition;
     修改后:private RdbmsSecuredMethodDefinition rdbmsSecuredMethodDefinition;

    以下两个函数,黑体为修改或增加部分:

    Java代码 复制代码 收藏代码
    1. protected void initDao() throws Exception {   
    2.     this.<STRONG>rdbmsSecuredMethodDefinition</STRONG> =    
    3.         new <STRONG>RdbmsSecuredMethodDefinition</STRONG>(this.getDataSource()); // 传入数据源, 此数据源由Spring配置文件注入   
    4.     ……   
    5. }   
    6.   
    7. public ConfigAttributeDefinition getAttributes(Object object) throws IllegalArgumentException {   
    8.     if ((object == null) || !this.supports(object.getClass())) {   
    9.         throw new IllegalArgumentException("抱歉,目标对象不是MethodInvocation类型");   
    10.     }   
    11.        
    12.     <STRONG>Method method = ((MethodInvocation) object).getMethod();</STRONG>   
    13.        
    14.     List list = this.getRdbmsEntryHolderList();   
    15.     if (list == null || list.size() == 0)   
    16.         return null;   
    17.        
    18.     <STRONG>// 获取方法全称, 如java.util.Set.isEmpty   
    19.     String methodString = method.getDeclaringClass().getName() + "." + method.getName();</STRONG>   
    20.        
    21.     String mappedName;   
    22.     Iterator it = list.iterator();   
    23.     <STRONG>// 循环判断当前访问的方法是否设置了角色访问机制, 有则返回ConfigAttributeDefinition(角色集合), 否则返回null</STRONG>   
    24.     while (it.hasNext()) {   
    25.         RdbmsEntryHolder entryHolder = (RdbmsEntryHolder) it.next();   
    26.         <STRONG>mappedName = entryHolder.getMethod();   
    27.         boolean matched = pathMatcher.match(entryHolder.getMethod(), methodString);</STRONG>   
    28.         //boolean matched = methodString.equals(mappedName) || isMatch(methodString, mappedName);   
    29.         if (logger.isDebugEnabled()) {   
    30.             logger.debug("匹配到如下Method: '" + methodString + ";模式为 "  
    31.                     + entryHolder.getMethod() + ";是否被匹配:" + matched);   
    32.         }   
    33.   
    34.         // 如果在用户所有被授权的URL中能找到匹配的, 则返回该ConfigAttributeDefinition(角色集合)   
    35.         if (matched) {   
    36.             return entryHolder.getCad();   
    37.         }   
    38.     }   
    39.        
    40.     return null;   
    41. }  
    protected void initDao() throws Exception {
    	this.rdbmsSecuredMethodDefinition = 
    		new RdbmsSecuredMethodDefinition(this.getDataSource()); // 传入数据源, 此数据源由Spring配置文件注入
    	……
    }
    
    public ConfigAttributeDefinition getAttributes(Object object) throws IllegalArgumentException {
    	if ((object == null) || !this.supports(object.getClass())) {
    		throw new IllegalArgumentException("抱歉,目标对象不是MethodInvocation类型");
    	}
    	
    	Method method = ((MethodInvocation) object).getMethod();
    	
    	List list = this.getRdbmsEntryHolderList();
    	if (list == null || list.size() == 0)
    		return null;
    	
    	// 获取方法全称, 如java.util.Set.isEmpty
    	String methodString = method.getDeclaringClass().getName() + "." + method.getName();
    	
    	String mappedName;
    	Iterator it = list.iterator();
    	// 循环判断当前访问的方法是否设置了角色访问机制, 有则返回ConfigAttributeDefinition(角色集合), 否则返回null
    	while (it.hasNext()) {
    		RdbmsEntryHolder entryHolder = (RdbmsEntryHolder) it.next();
    		mappedName = entryHolder.getMethod();
    		boolean matched = pathMatcher.match(entryHolder.getMethod(), methodString);
    		//boolean matched = methodString.equals(mappedName) || isMatch(methodString, mappedName);
    		if (logger.isDebugEnabled()) {
    			logger.debug("匹配到如下Method: '" + methodString + ";模式为 "
    					+ entryHolder.getMethod() + ";是否被匹配:" + matched);
    		}
    
    		// 如果在用户所有被授权的URL中能找到匹配的, 则返回该ConfigAttributeDefinition(角色集合)
    		if (matched) {
    			return entryHolder.getCad();
    		}
    	}
    	
    	return null;
    }
    

     4、通过Spring DI注入RdbmsMethodDefinitionSource

    Xml代码 复制代码 收藏代码
    1. <bean id="contactManagerSecurity"    
    2.     class="org.acegisecurity.intercept.method.aopalliance.MethodSecurityInterceptor">  
    3.     <property name="authenticationManager" ref="authenticationManager" />  
    4.     <property name="accessDecisionManager" ref="httpRequestAccessDecisionManager" />  
    5.     <property name="objectDefinitionSource" ref="<STRONG>rdbmsMethodDefinitionSource</STRONG>" />  
    6. </bean>  
    7.   
    8. <bean id="<STRONG>rdbmsMethodDefinitionSource</STRONG>" class="sample.service.impl.<STRONG>RdbmsMethodDefinitionSource</STRONG>">  
    9.     <property name="dataSource" ref="dataSource" />  
    10.     <property name="webresdbCache" ref="webresCacheBackend" />  
    11. </bean>  
    12.   
    13. <bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean"/>  
    14.   
    15. <bean id="webresCacheBackend"    
    16.     class="org.springframework.cache.ehcache.EhCacheFactoryBean">  
    17.     <property name="cacheManager">  
    18.         <ref local="cacheManager"/>  
    19.     </property>  
    20.     <property name="cacheName">  
    21.         <value>webresdbCache</value>  
    22.     </property>  
    23. </bean>  
    <bean id="contactManagerSecurity" 
    	class="org.acegisecurity.intercept.method.aopalliance.MethodSecurityInterceptor">
    	<property name="authenticationManager" ref="authenticationManager" />
    	<property name="accessDecisionManager" ref="httpRequestAccessDecisionManager" />
    	<property name="objectDefinitionSource" ref="rdbmsMethodDefinitionSource" />
    </bean>
    
    <bean id="rdbmsMethodDefinitionSource" class="sample.service.impl.RdbmsMethodDefinitionSource">
    	<property name="dataSource" ref="dataSource" />
    	<property name="webresdbCache" ref="webresCacheBackend" />
    </bean>
    
    <bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean"/>
    
    <bean id="webresCacheBackend" 
    	class="org.springframework.cache.ehcache.EhCacheFactoryBean">
    	<property name="cacheManager">
    		<ref local="cacheManager"/>
    	</property>
    	<property name="cacheName">
    		<value>webresdbCache</value>
    	</property>
    </bean>

          至此,我们此节所讲的内容已结束,大家可以下载源代码以便调试。另外,代码中还提供了另一个版本的实现类RdbmsMethodDefinitionSourceVersion2,它继承了AbstractMethodDefinitionSource,在一定程序上减少了代码量,朋友们可以自行研究。

    三、其他说明
    1、数据库
    在项目Acegi9的WebRoot/db目录下存放有相关数据库脚本,本节所采用的数据库版本是MySQL 5.0。


    2、环境说明
    开发环境:

    MyEclipse 5.0GA
    Eclipse3.2.1
    JDK1.5.0_10
    tomcat5.5.23
    acegi-security-1.0.7
    Spring2.0


    Jar包:
    acegi-security-1.0.7.jar
    commons-codec.jar
    jstl.jar(1.1)
    spring.jar(2.0.8)
    standard.jar
    commons-logging.jar(1.0)
    ehcache-1.3.0.jar
    c3p0-0.9.0.jar
    log4j-1.2.13.jar
    mysql-connector-java-3.1.10-bin.jar

          真不好意思,上面所说的黑体部分,在代码里面变成了<STRONG></STRONG>。也就是说,在该标签内的内容即为黑体部分

  • 相关阅读:
    TortoiseGit学习系列之TortoiseGit基本操作修改提交项目(图文详解)
    TortoiseGit学习系列之TortoiseGit基本操作克隆项目(图文详解)
    TortoiseGit学习系列之Windows上本地代码如何通过TortoiserGit提交到GitHub详解(图文)
    TortoiseGit学习系列之Windows上TortoiseGit的安装详解(图文)
    TortoiseGit学习系列之TortoiseGit是什么?
    Cloudera Manager集群官方默认的各个组件开启默认顺序(图文详解)
    IntelliJ IDEA 代码字体大小的快捷键设置放大缩小(很实用)(图文详解)
    Jenkins+Ant+SVN+Jmeter实现持续集成
    jmeter+Jenkins 持续集成中发送邮件报错:MessagingException message: Exception reading response
    jmeter+Jenkins持续集成(四、定时任务和邮件通知)
  • 原文地址:https://www.cnblogs.com/wzh123/p/3393009.html
Copyright © 2011-2022 走看看