手把手教你玩转Shiro(1)

导读:本篇文章讲解 手把手教你玩转Shiro(1),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

       我们知道,shiro就是一款优秀的权限管理框架,那么到底什么是权限?什么是对权限管理?对权限管理到底又有哪些部分呢?Shiro这个框架到底能够提供我们怎么的权限管理呢?Shiro是不是能对我们实际开发提高安全和效率呢?我们带着这些疑问来慢慢学习Shiro,我觉得这样学习的效率会更加高。

一:权限的前导知识

描述:下面的内容主要是为了后面学习shiro了解一些基本知识,并且这些内容都是出现在shiro里面的,所以请认真看一看,并且对实际开发过程是很有帮助的。

(1)权限管理知识

知识点一:什么是权限管理

       基本上涉及到用户参与的系统都要进行权限管理,权限管理属于系统安全的范畴,权限管理实现对用户访问系统的控制,按照安全规则或者安全策略控制用户可以访问而且只能访问自己被授权的资源。
权限管理包括用户身份认证和授权两部分,简称认证授权。对于需要访问控制的资源用户首先经过身份认证,认证通过后用户具有该资源的访问权限方可访问。

知识点二:用户身份认证

        身份认证,就是判断一个用户是否为合法用户的处理过程。最常用的简单身份认证方式是系统通过核对用户输入的用户名和口令,看其是否与系统中存储的该用户的用户名和口令一致,来判断用户身份是否正确。对于采用指纹等系统,则出示指纹;对于硬件Key等刷卡系统,则需要刷卡。

主要的流程是:

手把手教你玩转Shiro(1)

这里面包含的关键对象有:

上边的流程图中需要理解以下关键对象:
(1)Subject:主体
访问系统的用户,主体可以是用户、程序等,进行认证的都称为主体;
(2)Principal:身份信息
是主体(subject)进行身份认证的标识,标识必须具有唯一性,如用户名、手机号、邮箱地址等,一个主体可以有多个身份,但是必须有一个主身份(Primary Principal)。
(3)credential:凭证信息
是只有主体自己知道的安全信息,如密码、证书等。

知识点3:用户授权

      授权,即访问控制,控制谁能访问哪些资源。主体进行身份认证后需要分配权限方可访问系统的资源,对于某些资源没有权限是无法访问的。

主要的流程如下:

手把手教你玩转Shiro(1)

这里面包含的关键对象有:

授权可简单理解为who对what(which)进行How操作:
(1)Who,即主体(Subject),主体需要访问系统中的资源。
(2)What,即资源(Resource),如系统菜单、页面、按钮、类方法、系统商品信息等。资源包括资源类型和资源实例,比如商品信息为资源类型,类型为t01的商品为资源实例,编号为001的商品信息也属于资源实例。
(3)How,权限/许可(Permission),规定了主体对资源的操作许可,权限离开资源没有意义,如用户查询权限、用户添加权限、某个类方法的调用权限、编号为001用户的修改权限等,通过权限可知主体对哪些资源都有哪些操作许可。
权限分为粗颗粒和细颗粒,粗颗粒权限是指对资源类型的权限,细颗粒权限是对资源实例的权限。

手把手教你玩转Shiro(1)

知识点四:权限模型

针对上面的主体,资源,权限,我们可以得到如下的权限模型

主体(账号、密码)
资源(资源名称、访问地址)
权限(权限名称、资源id)
角色(角色名称)
角色和权限关系(角色id、权限id)
主体和角色关系(主体id、角色id)

用如下的图表示:

手把手教你玩转Shiro(1)

通常企业开发中将资源和权限表合并为一张权限表,如下:
资源(资源名称、访问地址)
权限(权限名称、资源id)
合并为:
权限(权限名称、资源名称、资源访问地址)

手把手教你玩转Shiro(1)

知识点五:基于角色的访问控制和基于权限的访问控制

RBAC基于角色的访问控制(Role-Based Access Control)是以角色为中心进行访问控制,比如:主体的角色为总经理可以查询企业运营报表,查询员工工资信息等,访问控制流程如下:

手把手教你玩转Shiro(1)

 

缺点:以角色进行访问控制粒度较粗,如果上图中查询工资所需要的角色变化为总经理和部门经理,此时就需要修改判断逻辑为“判断主体的角色是否是总经理或部门经理”,系统可扩展性差。
修改代码如下:
if(主体.hasRole(“总经理角色id”) ||  主体.hasRole(“部门经理角色id”)){
查询工资
}
那么上面的这种方案不是特别好,那如何进行解决呢?

 

RBAC基于资源的访问控制(Resource-Based Access Control)是以资源为中心进行访问控制,比如:主体必须具有查询工资权限才可以查询员工工资信息等,访问控制流程如下:
上图中的判断逻辑代码可以理解为:
if(主体.hasPermission(“查询工资权限标识”)){
查询工资
}
优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也只需要将“查询工资信息权限”添加到“部门经理角色”的权限列表中,判断逻辑不用修改,系统可扩展性强。

知识点六:粗粒度权限和细粒度权限(了解这个才知道,对于不同的权限,如何进行解决)

       对资源类型的管理称为粗颗粒度权限管理,即只控制到菜单、按钮、方法,粗粒度的例子比如:用户具有用户管理的权限,具有导出订单明细的权限。对资源实例的控制称为细颗粒度权限管理,即控制到数据级别的权限,比如:用户只允许修改本部门的员工信息,用户只允许导出自己创建的订单明细。

(1)针对粗粒度权限的解决方法

对于粗颗粒度的权限管理可以很容易做系统架构级别的功能,即系统功能操作使用统一的粗颗粒度的权限管理。

(2)针对细粒度权限的解决方法

对于细颗粒度的权限管理不建议做成系统架构级别的功能,因为对数据级别的控制是系统的业务需求,随着业务需求的变更业务功能变化的可能性很大,建议对数据级别的权限控制在业务层个性化开发,比如:用户只允许修改自己创建的商品信息可以在service接口添加校验实现,service接口需要传入当前操作人的标识,与商品信息创建人标识对比,不一致则不允许修改商品信息。

二:基于URL拦截进行权限管理

(1)描述:这里先介绍一下如何不用Shiro进行权限管理的解决方法,这也是在企业中用得非常多的办法。

基于url拦截是企业中常用的权限管理方法,实现思路是:将系统操作的每个url配置在权限表中,将权限对应到角色,将角色分配给用户,用户访问系统功能通过Filter进行过虑,过虑器获取到用户访问的url,只要访问的url是用户分配角色中的url则放行继续访问。

手把手教你玩转Shiro(1)

(2)基于URL权限管理的示例开发

环境:IDEA+SpringMVC+Spring+Mybatis+Mysql

1:数据库如下所示:

手把手教你玩转Shiro(1)

每个表的详细如下所示:

手把手教你玩转Shiro(1)(user)

手把手教你玩转Shiro(1)(permission)

手把手教你玩转Shiro(1)(role_permission)

其中的role和user_role就是类似上面的对应关系,不进行多描述。

这就对应了我在前导知识中说的主体,资源,权限之间的关系。

2:设置公共URL(这里面的URL是不需要登陆即可进行访问的,即对所有用户都是进行放行状态)

在开发的过程中,我们一般都是把这个公共的URL放在一个properties文件中,比如:anonymousURL.properties

login.action = 用户登陆页面

3:设置读取公共URL的properties文件的工具类(这是一个可以通用的工具类的,以后其他地方可以使用)

package com.hnu.scw.util;
import java.io.Serializable;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.Set;

/**
 * 资源文件读取工具类
 */
public class ResourcesUtil implements Serializable {
	private static final long serialVersionUID = -7657898714983901418L;
	/**
	 * 系统语言环境,默认为中文zh
	 */
	public static final String LANGUAGE = "zh";
	/**
	 * 系统国家环境,默认为中国CN
	 */
	public static final String COUNTRY = "CN";
	private static Locale getLocale() {
		Locale locale = new Locale(LANGUAGE, COUNTRY);
		return locale;
	}
	/**
	 * 根据语言、国家、资源文件名和key名字获取资源文件值
	 * 
	 * @param language
	 *            语言
	 * 
	 * @param country
	 *            国家
	 * 
	 * @param baseName
	 *            资源文件名
	 * 
	 * @param section
	 *            key名字
	 * 
	 * @return 值
	 */
	private static String getProperties(String baseName, String section) {
		String retValue = "";
		try {
			Locale locale = getLocale();
			ResourceBundle rb = ResourceBundle.getBundle(baseName, locale);
			retValue = (String) rb.getObject(section);
		} catch (Exception e) {
			e.printStackTrace();
			// TODO 添加处理
		}
		return retValue;
	}

	/**
	 * 通过key从资源文件读取内容
	 * 
	 * @param fileName
	 *            资源文件名
	 * 
	 * @param key
	 *            索引
	 * 
	 * @return 索引对应的内容
	 */
	public static String getValue(String fileName, String key) {
		String value = getProperties(fileName,key);
		return value;
	}

	public static List<String> gekeyList(String baseName) {
		Locale locale = getLocale();
		ResourceBundle rb = ResourceBundle.getBundle(baseName, locale);

		List<String> reslist = new ArrayList<String>();

		Set<String> keyset = rb.keySet();
		for (Iterator<String> it = keyset.iterator(); it.hasNext();) {
			String lkey = (String)it.next();
			reslist.add(lkey);
		}
		return reslist;
	}
	/**
	 * 通过key从资源文件读取内容,并格式化
	 * 
	 * @param fileName
	 *            资源文件名
	 * @param key
	 *            索引
	 * 
	 * @param objs
	 *            格式化参数
	 * 
	 * @return 格式化后的内容
	 */
	public static String getValue(String fileName, String key, Object[] objs) {
		String pattern = getValue(fileName, key);
		String value = MessageFormat.format(pattern, objs);
		return value;
	}
	public static void main(String[] args) {
		System.out.println(getValue("resources.messages", "101",new Object[]{100,200}));	
		//根据操作系统环境获取语言环境
		/*Locale locale = Locale.getDefault();
		System.out.println(locale.getCountry());//输出国家代码
		System.out.println(locale.getLanguage());//输出语言代码s	
		//加载国际化资源(classpath下resources目录下的messages.properties,如果是中文环境会优先找messages_zh_CN.properties)
		ResourceBundle rb = ResourceBundle.getBundle("resources.messages", locale);
		String retValue = rb.getString("101");//101是messages.properties文件中的key
		System.out.println(retValue);	
		//信息格式化,如果资源中有{}的参数则需要使用MessageFormat格式化,Object[]为传递的参数,数量根据资源文件中的{}个数决定
		String value = MessageFormat.format(retValue, new Object[]{100,200});
		System.out.println(value);
*/
	}
}

4:设置springmvc中的对于公共URL的拦截器(即,判断访问的URL是否是公共URL,如果是,则可以进行放行)

package com.hnu.scwcontroller.interceptor;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import cn.itcast.ssm.po.ActiveUser;
import cn.itcast.ssm.util.ResourcesUtil;

/**
 * 
 * <p>Title: HandlerInterceptor1</p>
 * <p>Description: 用户身份认证拦截器</p>
 * @author	scw
 * @date	2018-1-11
 * @version 1.0
 */
public class LoginInterceptor implements HandlerInterceptor {

	//在执行handler之前来执行的
	//用于用户认证校验、用户权限校验
	@Override
	public boolean preHandle(HttpServletRequest request,
			HttpServletResponse response, Object handler) throws Exception {		
		//得到请求的url
		String url = request.getRequestURI();	
		//判断是否是公开 地址
		//实际开发中需要公开 地址配置在配置文件中
		//从配置中取匿名访问url(参数传入公共URL的properties的文件名即可,不需要后缀)
		List<String> open_urls = ResourcesUtil.gekeyList("anonymousURL");
		//遍历公开 地址,如果是公开 地址则放行
		for(String open_url:open_urls){
			if(url.indexOf(open_url)>=0){
				//如果是公开 地址则放行
				return true;
			}
		}
		//如果不是公共的URL,那么判断session中是否保存了用户的信息(在一定的时间内)
		//判断用户身份在session中是否存在
		HttpSession session = request.getSession();
		ActiveUser activeUser = (ActiveUser) session.getAttribute("activeUser");
		//如果用户身份在session中存在放行
		if(activeUser!=null){
			return true;
		}
		//执行到这里拦截,跳转到登陆页面,用户进行身份认证
		request.getRequestDispatcher("/WEB-INF/jsp/login.jsp").forward(request, response);
		
		//如果返回false表示拦截不继续执行handler,如果返回true表示放行
		return false;
	}
	//在执行handler返回modelAndView之前来执行
	//如果需要向页面提供一些公用 的数据或配置一些视图信息,使用此方法实现 从modelAndView入手
	@Override
	public void postHandle(HttpServletRequest request,
			HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
		System.out.println("HandlerInterceptor1...postHandle");
		
	}
	//执行handler之后执行此方法
	//作系统 统一异常处理,进行方法执行性能监控,在preHandle中设置一个时间点,在afterCompletion设置一个时间,两个时间点的差就是执行时长
	//实现 系统 统一日志记录
	@Override
	public void afterCompletion(HttpServletRequest request,
			HttpServletResponse response, Object handler, Exception ex)
			throws Exception {
		System.out.println("HandlerInterceptor1...afterCompletion");
	}

}

5:在springmvc的配置文件中,配置上面的拦截器

<!--拦截器 -->
	<mvc:interceptors>
		<mvc:interceptor>
			<!-- 用户认证拦截 -->
			<mvc:mapping path="/**" />
			<bean class="com.hnu.scw.controller.interceptor.LoginInterceptor"></bean>
		</mvc:interceptor>
	</mvc:interceptors>

6:通过上面的配置的话,如果用户没有进行登陆验证的话,那么就无法访问其他的页面,因为公共URL,这里只配置了登陆页面的URL,从而实现了其他URL的访问权限控制(属于粗粒度的控制)。注意:当用户在登陆页面进行登陆之后,会在service层对用户输入的账号和密码进行验证,当验证通过之后,会把该用户的信息保存到session中,方便其他地方进行使用。保存的信息内容,如下所示:

public class ActiveUser implements java.io.Serializable {
	private String userid;//用户id(主键)
	private String usercode;// 用户账号
	private String username;// 用户名称
	private List<SysPermission> menus;// 菜单
	private List<SysPermission> permissions;// 权限

	public String getUsername() {
		return username;
	}
	public void setUsername(String username) {
		this.username = username;
	}
	public String getUsercode() {
		return usercode;
	}
	public void setUsercode(String usercode) {
		this.usercode = usercode;
	}
	public String getUserid() {
		return userid;
	}
	public void setUserid(String userid) {
		this.userid = userid;
	}
	public List<SysPermission> getMenus() {
		return menus;
	}
	public void setMenus(List<SysPermission> menus) {
		this.menus = menus;
	}
	public List<SysPermission> getPermissions() {
		return permissions;
	}
	public void setPermissions(List<SysPermission> permissions) {
		this.permissions = permissions;
	}
}

我们可以看到封装的这个model类中,有两个字段比较特别,就是权限和菜单,其含义就是,该用户信息能够访问主页的菜单项内容和访问的URL权限。。这个我们可以在数据库表中的”sys_permission”中获取。那么在mapper中的sql语句,其实就可以通过下面的方法进行获取(直接在service层中调用mapper接口,再调用这两个方法即可,然后封装到我们上面的那个类中即可):

 <!-- 根据用户id查询url -->
  <select id="findPermissionListByUserId" parameterType="string" resultType="cn.itcast.ssm.po.SysPermission">
	  SELECT 
	  * 
	FROM
	  sys_permission 
	WHERE TYPE = 'permission' 
	  AND id IN 
	  (SELECT 
	    sys_permission_id 
	  FROM
	    sys_role_permission 
	  WHERE sys_role_id IN 
	    (SELECT 
	      sys_role_id 
	    FROM
	      sys_user_role 
	    WHERE sys_user_id = #{id}))
  </select>
  
   <!-- 根据用户id查询菜单 -->
  <select id="findMenuListByUserId"  parameterType="string" resultType="cn.itcast.ssm.po.SysPermission">
  		SELECT 
	  * 
	FROM
	  sys_permission 
	WHERE TYPE = 'menu' 
	  AND id IN 
	  (SELECT 
	    sys_permission_id 
	  FROM
	    sys_role_permission 
	  WHERE sys_role_id IN 
	    (SELECT 
	      sys_role_id 
	    FROM
	      sys_user_role 
	    WHERE sys_user_id = #{id}))
  </select>

7:通过上面的操作,当用户正确登陆之后,访问的主页的时候,就只会显示对应的用户的菜单项了。OK,那么问题来了,可能有些页面中的某些按钮是需要权限才可以进行访问的URL,所以,类似公共URL的方法,这里就还需要判断是否有相应的权限信息。所以,我们还需要再写一个授权拦截器:

package com.hnu.scw.controller.interceptor;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import cn.itcast.ssm.po.ActiveUser;
import cn.itcast.ssm.po.SysPermission;
import cn.itcast.ssm.util.ResourcesUtil;

/**
 * 
 * <p>Title: HandlerInterceptor1</p>
 * <p>Description: 授权拦截器</p>
 * @author	scw
 * @date	2018-1-11
 * @version 1.0
 */
public class PermissionInterceptor implements HandlerInterceptor {
	//在执行handler之前来执行的
	//用于用户认证校验、用户权限校验
	@Override
	public boolean preHandle(HttpServletRequest request,
			HttpServletResponse response, Object handler) throws Exception {
		
		//得到请求的url
		String url = request.getRequestURI();
		
		//判断是否是公开 地址
		//实际开发中需要公开 地址配置在配置文件中
		//从配置中取逆名访问url
		
		List<String> open_urls = ResourcesUtil.gekeyList("anonymousURL");
		//遍历公开 地址,如果是公开 地址则放行
		for(String open_url:open_urls){
			if(url.indexOf(open_url)>=0){
				//如果是公开 地址则放行
				return true;
			}
		}
		
		//从配置文件中获取公共访问地址(公共地址的形式和公开URL是类似的配置,这里就没有多说,还是创建一个properties的文件,放入公开的URL即可,公开的URL,比如退出登陆和主页这都是属于公开URL的,只需要用户认证之后就可以访问的页面URL)
		List<String> common_urls = ResourcesUtil.gekeyList("commonURL");
		//遍历公用 地址,如果是公用 地址则放行
		for(String common_url:common_urls){
			if(url.indexOf(common_url)>=0){
				//如果是公开 地址则放行
				return true;
			}
		}
		
		//获取session
		HttpSession session = request.getSession();
		ActiveUser activeUser = (ActiveUser) session.getAttribute("activeUser");
		//从session中取权限范围的url,这个就是我们上面在登陆的时候保存的那两个字段的作用
		List<SysPermission> permissions = activeUser.getPermissions();
		for(SysPermission sysPermission:permissions){
			//权限的url
			String permission_url = sysPermission.getUrl();
			if(url.indexOf(permission_url)>=0){
				//如果是权限的url 地址则放行
				return true;
			}
		}

		//执行到这里拦截,如果该用户进行的是没有相应权限的操作,那么就跳转到无权访问的提示页面
		request.getRequestDispatcher("/WEB-INF/jsp/refuse.jsp").forward(request, response);
	
		//如果返回false表示拦截不继续执行handler,如果返回true表示放行
		return false;
	}
	//在执行handler返回modelAndView之前来执行
	//如果需要向页面提供一些公用 的数据或配置一些视图信息,使用此方法实现 从modelAndView入手
	@Override
	public void postHandle(HttpServletRequest request,
			HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
		System.out.println("HandlerInterceptor1...postHandle");
		
	}
	//执行handler之后执行此方法
	//作系统 统一异常处理,进行方法执行性能监控,在preHandle中设置一个时间点,在afterCompletion设置一个时间,两个时间点的差就是执行时长
	//实现 系统 统一日志记录
	@Override
	public void afterCompletion(HttpServletRequest request,
			HttpServletResponse response, Object handler, Exception ex)
			throws Exception {
		System.out.println("HandlerInterceptor1...afterCompletion");
	}

}

8:同理,再将上面的这一个拦截器配置到springmvc中。所以,现在就有两个拦截器了,一个是身份认证,一个是身份授权

<!--拦截器 -->
	<mvc:interceptors>
		<mvc:interceptor>
			<!-- 用户认证拦截 -->
			<mvc:mapping path="/**" />
			<bean class="com.hnu.scw.controller.interceptor.LoginInterceptor"></bean>
		</mvc:interceptor>
		<mvc:interceptor>
			<!-- 授权拦截 -->
			<mvc:mapping path="/**" />
			<bean class="com.hnu.scw.controller.interceptor.PermissionInterceptor"></bean>
		</mvc:interceptor>
	</mvc:interceptors>

9:OK,到这里的话,我们已经实现了URL权限管理的开发了,只有符合我们自己写的URL的内容和角色对应的话,才能够进行正常的访问。当然这里没有把如何整合Spring+SpringMVC+Mybatis添加进来,这个的话,可以参考我的另外一篇文章,SSM框架的整合开发

10:这一步是可选项,因为我们在开发中,会碰到抛出异常的情况,而且一般都是dao抛出—》service—-》controller,那么我们就需要一个统一的异常处理器了。那么可以如下配置:

package com.hnu.scw.exception;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

/**
 * 
 * <p>Title: CustomExceptionResolver</p>
 * <p>Description: 自定义异常处理器</p>
 * @author	scw
 * @date	2018-1-11
 * @version 1.0
 */
public class CustomExceptionResolver implements HandlerExceptionResolver  {

	//前端控制器DispatcherServlet在进行HandlerMapping、调用HandlerAdapter执行Handler过程中,如果遇到异常就会执行此方法
	//handler最终要执行的Handler,它的真实身份是HandlerMethod
	//Exception ex就是接收到异常信息
	@Override
	public ModelAndView resolveException(HttpServletRequest request,
			HttpServletResponse response, Object handler, Exception ex) {
		//输出异常
		ex.printStackTrace();
		
		//统一异常处理代码
		//针对系统自定义的CustomException异常,就可以直接从异常类中获取异常信息,将异常处理在错误页面展示
		//异常信息
		String message = null;
		CustomException customException = null;
		//如果ex是系统 自定义的异常,直接取出异常信息
		if(ex instanceof CustomException){
			customException = (CustomException)ex;
		}else{
			//针对非CustomException异常,对这类重新构造成一个CustomException,异常信息为“未知错误”
			customException = new CustomException("未知错误");
		}
		
		//错误 信息
		message = customException.getMessage();		
		request.setAttribute("message", message);	
		try {
			//转向到错误 页面
			request.getRequestDispatcher("/WEB-INF/jsp/error.jsp").forward(request, response);
		} catch (ServletException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}	
		return new ModelAndView();
	}

}

然后在springmvc中添加异常处理配置信息:

<!-- 定义统一异常处理器 -->
	<bean class="com.hnu.scw.exception.CustomExceptionResolver"></bean>

总结:

 

通过上面的这种URL权限管理,我们会感觉到,配置好麻烦,对于URL的管理又需要根据数据库中进行关联,那么随着系统不断的扩大,那么配置的URL信息会很多,不利于扩展性和维护性。但是,不需要利用框架进行开发。那么,如果解决这种问题呢?那么Shiro框架的使用就显得非常重要了,想知道如何进行工作的吗?那么请继续学习后面一篇Shiro的实践的文章。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/12417.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!