1. 程式人生 > 程式設計 >關於Shiro框架的學習(二)

關於Shiro框架的學習(二)

前言

接上篇,關於Shiro框架的學習(一),這篇會記錄下Shiro整合Web、整合SSM的過程,之後就可以直接應用在專案的安全控制上。

關於整合Web

  • 環境

    Eclipse、MySQL、Tomcat8
  • 準備工作

    • 建立Dynamic Web工程:

Dynamic工程
- 使用到的類 準備User實體類、ShiroDao類、DatabaseRealm類,這三個類在上一篇文章中已經提及,這裡不再重複贅述。 - 資料庫 沿用上一篇博文中未加密的資料庫,資料庫指令碼上一篇已提及,同樣不再重複貼程式碼了。 - jar包: 本次需要用到的jar包主要有如下幾個

jar

  • 修改shiro.ini配置檔案和web.xml配置檔案

    配置檔案中指定了尋找DatabaseRealm的方法、指定了每個頁面需要什麼角色和許可權、指定了如果沒有許可權將會跳轉到哪個頁面。

    [main] 
    #使用資料庫進行驗證和授權
    databaseRealm=com.shiro.DatabaseRealm
    securityManager.realms=$databaseRealm
    
    #當訪問需要驗證的頁面,但是又沒有驗證的情況下,跳轉到login.jsp
    authc.loginUrl=/login.jsp
    #當訪問需要角色的頁面,但是又不擁有這個角色的情況下,跳轉到noroles.jsp
    roles.unauthorizedUrl=/noRoles.jsp
    #當訪問需要許可權的頁面,但是又不擁有這個許可權的情況下,跳轉到noperms.jsp
    perms.unauthorizedUrl=/noPerms.jsp #users,roles和perms都通過前面知識點的資料庫配置了 [users] #urls用來指定哪些資源需要什麼對應的授權才能使用 [urls] #doLogout地址就會進行退出行為 /doLogout=logout #login.jsp,noroles.jsp,noperms.jsp 可以匿名訪問 /login.jsp=anon /noroles.jsp=anon /noperms.jsp=anon #閱讀部落格,需要登入後才可以檢視 /readBlog.jsp=authc #新增部落格不僅需要登入,而且要擁有 blogManager 角色才可以操作
    /addBlog.jsp=authc,roles[blogManager] #刪除部落格,不僅需要登入,而且要擁有 deleteBlog 許可權才可以操作 /deleteBlog.jsp=authc,perms["deleteBlog"] 複製程式碼

    web.xml配置了載入shiro.ini的配置 在<web-app>中配置

    <listener>
    <listener-class>org.apache.shiro.web.env.EnvironmentLoaderListener</listener-class>
    </listener>
    <context-param>
      <param-name>shiroEnvironmentClass</param-name>
      <param-value>org.apache.shiro.web.env.IniWebEnvironment</param-value>
      <!-- 預設先從/WEB-INF/shiro.ini,如果沒有找classpath:shiro.ini -->
    </context-param>
    <context-param>
      <param-name>shiroConfigLocations</param-name>
      <param-value>classpath:shiro.ini</param-value>
    </context-param>
    <filter>
      <filter-name>shiroFilter</filter-name>
      <filter-class>org.apache.shiro.web.servlet.ShiroFilter</filter-class>
    </filter>
    <filter-mapping>
      <filter-name>shiroFilter</filter-name>
      <url-pattern>/*</url-pattern>
    </filter-mapping>
    複製程式碼
  • Servlet

    新建一個LoginServlet,負責控制登入驗證。

    @WebServlet(name = "loginServlet",urlPatterns = "/login") 
    public class LoginServlet extends HttpServlet {
    	private static final long serialVersionUID = 1L;
    	protected void doPost(HttpServletRequest request,HttpServletResponse response) throws ServletException,IOException {
    	String name = request.getParameter("name"); 
    String password = request.getParameter("password"); 
    Subject subject = SecurityUtils.getSubject(); 
    UsernamePasswordToken token = new UsernamePasswordToken(name,password); 
    try { 
      subject.login(token);
    		//通過subject獲取session
      Session session=subject.getSession();
      session.setAttribute("subject",subject);
      response.sendRedirect("");
    }catch (AuthenticationException e) { 
      request.setAttribute("error","驗證失敗"); 
      request.getRequestDispatcher("login.jsp").forward(request,response);
    		} 
    	}
    }
    複製程式碼
  • Jsp前臺頁

    • index.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
    <html>
    <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    
    <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
    
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    
    </head>
    <body>
    
    <div class="workingroom">
    	<div class="loginDiv">
    	<c:if test="${empty subject.principal}">
    		<a href="login.jsp">登入</a><br>
    	</c:if>
    	<c:if test="${!empty subject.principal}">
    		<span class="desc">你好,${subject.principal},</span>
    		<a href="doLogout">退出</a><br>
    	</c:if>
    	<a href="readBlog.jsp">檢視部落格</a><span class="desc">(登入後才可以檢視) </span><br>
    	<a href="addBlog.jsp">新增部落格</a><span  class="desc">(要有部落格管理員角色,Reader是讀者,Object是部落格管理員) </span><br>
    	<a href="deleteBlog.jsp">刪除部落格</a><span class="desc">(要有刪除訂單許可權,Object有該許可權) </span><br>
    </div>
    
    </body>
    </html>
    複製程式碼
    • login.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8" import="java.util.*"%>
    
    <!DOCTYPE html>
    
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    
    <div class="workingroom">
    
    <div class="errorInfo">${error}</div>
    	<form action="login" method="post">
    		賬號: <input type="text" name="name"> <br>
    		密碼: <input type="password" name="password"> <br>
    		<br>
    		<input type="submit" value="登入">
    		<br>
    		<br>
    		<div>
    			<span class="desc">賬號:Object 密碼:123456 角色:blogManager</span><br>
    			<span class="desc">賬號:Reader 密碼:654321 角色:reader</span><br>
    		</div>
    	</form>
    </div>
    複製程式碼
    • readBlog.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8" import="java.util.*"%>
    
    <!DOCTYPE html>
    
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    <div class="workingroom">
    	readBlog.jsp ,能進來,就表示已經登入成功了
    	<br>
    	<a href="#" onClick="javascript:history.back()">返回</a>
    </div>
    複製程式碼
    • addBlog.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8" import="java.util.*"%>
    
    <!DOCTYPE html>
    
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    
    <div class="workingroom">
    	addBlog.jsp,能進來<br>就表示擁有 blogManager 角色
    	<br>
    	<a href="#" onClick="javascript:history.back()">返回</a>
    </div>
    複製程式碼
    • deleteBlog.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8" import="java.util.*"%>
    <!DOCTYPE html>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    
    <div class="workingroom">
    	deleteBlog.jsp ,能進來,就表示有deleteBlog許可權
    	<br>
    	<a href="#" onClick="javascript:history.back()">返回</a>
    </div>
    複製程式碼
    • noRoles.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8" import="java.util.*"%>
    <!DOCTYPE html>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    <div class="workingroom">
    	角色不匹配
    	<br>
    	<a href="#" onClick="javascript:history.back()">返回</a>
    </div>
    複製程式碼
    • noPerms.jsp
    <%@ page language="java" contentType="text/html; charset=UTF-8"
    		pageEncoding="UTF-8" import="java.util.*"%>
    <!DOCTYPE html>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" type="text/css" href="static/css/style.css" />
    <div class="workingroom">
    	許可權不足
    	<br>
    	<a href="#" onClick="javascript:history.back()">返回</a>
    </div>
    複製程式碼
    • style.css(頁面樣式)
    span.desc{
    margin-left:20px;
    color:gray;
    }
    div.workingroom{
    		margin:200px auto;
    		width:400px;
    }
    div.workingroom a{
    		display:inline-block;
    		margin-top:20px;
    }
    div.loginDiv{
    		text-align: left;
    }
    div.errorInfo{
    		color:red;
    		font-size:0.65em;
    }
    複製程式碼
  • 測試

開啟tomcat伺服器,在瀏覽器url輸入:localhost:8080/ShiroWeb

登陸介面
登入Object

登陸成功
點選各功能:

登入訪問成功

訪問成功

許可權訪問成功
登入Reader後點選各功能: 除了ReadBlog可以進以外,其餘都失敗

登入訪問成功2

許可權訪問失敗

角色訪問失敗

關於整合SSM

  • 使用@RequireRoles註解

核心:

SSM註解
這種方式只需要在Controller對映頁面的方法上加上@RequireRoles("需要的許可權")就可以輕鬆控制頁面所需的許可權了,但是在真實開發中,如果許可權改變,那麼你就需要一直去修改原始碼,這樣顯然不合適,所以這種方式一帶而過,可以在這裡瞭解:Shiro系列教材

  • 使用基於URL配置許可權

    這種方式主要是不用在每個頁面的對映上都加所需的許可權,而是動態地將角色資訊和許可權資訊寫入資料庫,再讀取資料庫,看頁面是否需要攔截,訪問頁面需要什麼許可權等。

    • 從零搭建 首先先修改表結構

      DROP DATABASE IF EXISTS shiro;
      CREATE DATABASE shiro DEFAULT CHARACTER SET utf8;
      USE shiro;
      
      drop table if exists user;
      drop table if exists role;
      drop table if exists permission;
      drop table if exists user_role;
      drop table if exists role_permission;
      
      create table user (
      	id bigint auto_increment,name varchar(100),password varchar(100),salt varchar(100),constraint pk_users primary key(id)
      ) charset=utf8 ENGINE=InnoDB;
      
      create table role (
      	id bigint auto_increment,desc_ varchar(100),constraint pk_roles primary key(id)
      ) charset=utf8 ENGINE=InnoDB;
      
      create table permission (
      	id bigint auto_increment,url varchar(100),constraint pk_permissions primary key(id)
      ) charset=utf8 ENGINE=InnoDB;
      
      create table user_role (
      	id bigint auto_increment,uid bigint,rid bigint,constraint pk_users_roles primary key(id)
      ) charset=utf8 ENGINE=InnoDB;
      
      create table role_permission (
      	id bigint auto_increment,pid bigint,constraint pk_roles_permissions primary key(id)
      ) charset=utf8 ENGINE=InnoDB;
      複製程式碼

      實際上上面這段程式碼就是對原來的表新增了幾個欄位,新增欄位如下: permission:desc_url role:desc_

      插入表資料:

      INSERT INTO `permission` VALUES (1,'addblog','新增部落格','/addBlog');
      INSERT INTO `permission` VALUES (2,'readerBlog','閱讀部落格','/readBlog');
      INSERT INTO `role` VALUES (1,'blogManager','部落格管理員');
      INSERT INTO `role` VALUES (2,'reader','讀者');
      INSERT INTO `role_permission` VALUES (1,1,1);
      INSERT INTO `role_permission` VALUES (2,2,2);
      INSERT INTO `user` VALUES (1,'Object','a7d59dfc5332749cb801f86a24f5f590','e5ykFiNwShfCXvBRPr3wXg==');
      INSERT INTO `user` VALUES (2,'Reader','43e28304197b9216e45ab1ce8dac831b','jPz19y7arvYIGhuUjsb6sQ==');
      INSERT INTO `user_role` VALUES (1,2);
      INSERT INTO `user_role` VALUES (2,1);
      複製程式碼
    • 配置檔案web.xml 在web.xml檔案中要配置四塊內容,分別是,中文過濾器Spring相關MVC相關Shiro過濾器相關。 先說說shiro過濾器

    <!-- Shiro-->
    <filter>
        <filter-name>shiroFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
        <init-param>
            <param-name>targetFilterLifecycle</param-name>
            <param-value>true</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>shiroFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    複製程式碼

    shiro過濾器預設攔截所有請求

    Spring相關:主要有兩個,一個是Spring整合Mybatis,一個是Spring整合Shiro

     <!-- spring -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>
            classpath:applicationContext.xml,classpath:applicationContext-shiro.xml
        </param-value>
    </context-param>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>
    複製程式碼

    MVC相關: 就是日常的 MVC配置

     <!-- spring mvc -->
      <servlet>
          <servlet-name>mvc-dispatcher</servlet-name>
          <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
          <!-- spring mvc-->
          <init-param>
              <param-name>contextConfigLocation</param-name>
              <param-value>classpath:springMVC.xml</param-value>
          </init-param>
          <load-on-startup>1</load-on-startup>
      </servlet>
      <servlet-mapping>
          <servlet-name>mvc-dispatcher</servlet-name>
          <url-pattern>/</url-pattern>
      </servlet-mapping>
    複製程式碼
    • Spring中關於Shiro的配置: 1.SecurityManager:Shiro的核心安全管理類。
    <!-- 安全管理器 -->
    <bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
        <property name="realm" ref="databaseRealm" />
        <property name="sessionManager" ref="sessionManager" />
    </bean>
    <!-- 相當於呼叫SecurityUtils.setSecurityManager(securityManager) -->
    <bean
        class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
        <property name="staticMethod"
            value="org.apache.shiro.SecurityUtils.setSecurityManager" />
        <property name="arguments" ref="securityManager" />
    </bean>
    複製程式碼

    2.HashedCredentialsMatcher:密碼匹配器,可雜湊

     <!-- 密碼匹配器 -->
    <bean id="credentialsMatcher" class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
        <property name="hashAlgorithmName" value="md5"/>
        <property name="hashIterations" value="2"/>
        <property name="storedCredentialsHexEncoded" value="true"/>
    </bean>
    複製程式碼

    3.LifecycleBeanPostProcessor:保證了Shiro內部lifecycle函式的執行

    <!-- 保證實現了Shiro內部lifecycle函式的bean執行 -->
    <bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor" />
    複製程式碼

    4.ShiroFilterFactoryBean:shiro的過濾器工廠類

     <!-- url過濾器 -->          
    <bean id="urlPathMatchingFilter" class="com.shiro.filter.URLPathMatchingFilter"/>
         
    <!-- 配置shiro的過濾器工廠類,id- shiroFilter要和我們在web.xml中配置的過濾器一致 -->
    <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
        <!-- 呼叫我們配置的許可權管理器 -->
        <property name="securityManager" ref="securityManager" />
        <!-- 配置我們的登入請求地址 -->
        <property name="loginUrl" value="/login" />
        <!-- 如果您請求的資源不再您的許可權範圍,則跳轉到/403請求地址 -->
        <property name="unauthorizedUrl" value="/unauthorized" />
        <!-- 退出 -->
        <property name="filters">
            <util:map>
                <entry key="logout" value-ref="logoutFilter" />
                <entry key="url" value-ref="urlPathMatchingFilter" />
            </util:map>
        </property>
        <!-- 許可權配置 -->
        <property name="filterChainDefinitions" ref="filterChainDefinitions"/>
    </bean>
    <!-- 退出過濾器 -->
    <bean id="logoutFilter" class="org.apache.shiro.web.filter.authc.LogoutFilter">
        <property name="redirectUrl" value="/index" />
    </bean>
    複製程式碼

    5.FilterChainDefinitions:配置可從資料庫中讀取頁面許可權的LinkedHashMap

    <bean id="filterChainDefinitions" factory-bean="filterChainDefinitionsFactory" factory-method="buildFilterChainDefinitionMap"></bean>
    <bean id="filterChainDefinitionsFactory" class="com.shiro.entity.FilterChainDefinitions">
    </bean>
    複製程式碼

    6.會話相關

    <!-- 會話ID生成器 -->
    <bean id="sessionIdGenerator"
        class="org.apache.shiro.session.mgt.eis.JavaUuidSessionIdGenerator" />
    <!-- 會話Cookie模板 關閉瀏覽器立即失效 -->
    <bean id="sessionIdCookie" class="org.apache.shiro.web.servlet.SimpleCookie">
        <constructor-arg value="sid" />
        <property name="httpOnly" value="true" />
        <property name="maxAge" value="-1" />
    </bean>
    <!-- 會話DAO -->
    <bean id="sessionDAO"
        class="org.apache.shiro.session.mgt.eis.EnterpriseCacheSessionDAO">
        <property name="sessionIdGenerator" ref="sessionIdGenerator" />
    </bean>
    <!-- 會話驗證排程器,每30分鐘執行一次驗證 ,設定會話超時及儲存 -->
    <bean name="sessionValidationScheduler"
        class="org.apache.shiro.session.mgt.ExecutorServiceSessionValidationScheduler">
        <property name="interval" value="1800000" />
        <property name="sessionManager" ref="sessionManager" />
    </bean>
    <!-- 會話管理器 -->
    <bean id="sessionManager"
        class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
        <!-- 全域性會話超時時間(單位毫秒),預設30分鐘 -->
        <property name="globalSessionTimeout" value="1800000" />
        <property name="deleteInvalidSessions" value="true" />
        <property name="sessionValidationSchedulerEnabled" value="true" />
        <property name="sessionValidationScheduler" ref="sessionValidationScheduler" />
        <property name="sessionDAO" ref="sessionDAO" />
        <property name="sessionIdCookieEnabled" value="true" />
        <property name="sessionIdCookie" ref="sessionIdCookie" />
    </bean>
    複製程式碼

    7.自定義Realm

    <bean id="databaseRealm" class="com.shiro.service.impl.DatabaseRealm">
          <property name="credentialsMatcher" ref="credentialsMatcher"/>
    </bean>
    複製程式碼
    • 核心程式碼: 因為這是基於url的許可權管理,所以,就不會再有ini配置檔案了(實際上配置到Spring中),Realm直接從資料庫中獲取使用者的資訊,給使用者做驗證和授權操作。 最核心的程式碼還是自定義Realm:
    public class DatabaseRealm extends AuthorizingRealm {
    
      @Autowired
      private UserService userService;
      @Autowired
      private RoleService roleService;
      @Autowired
      private PermissionService permissionService;
    
      @Override
      protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        //獲取使用者名稱
        String userName = (String) principalCollection.getPrimaryPrincipal();
        //從資料庫中獲取角色和許可權
        Set<String> permissions = permissionService.getStringPermissionByName(userName);
        Set<String> roles = roleService.listPermissionURLs(userName);
    
        //建立簡單授權物件
        SimpleAuthorizationInfo s = new SimpleAuthorizationInfo();
        //設定許可權和角色
        s.setStringPermissions(permissions);
        s.setRoles(roles);
        //授權
        return s;
      }
    
      @Override
      protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken t = (UsernamePasswordToken) token;
        String userName = token.getPrincipal().toString();
        User user = userService.getUser(userName);
        String passwordInDB = user.getPassword();
        System.out.println(passwordInDB);
        System.out.println(t.getPassword());
        String salt = user.getSalt();
        //做使用者驗證
        SimpleAuthenticationInfo a = new SimpleAuthenticationInfo(userName,passwordInDB,ByteSource.Util.bytes(salt),getName());
        return a;
      }
    }
    複製程式碼

    Subject會將使用者的資訊交給上述Realm做驗證和授權,做驗證很好理解,登入即驗證,但是要怎麼判斷一個url是否要做許可權驗證呢? 在PermissionService中有兩個方法,分別是needIntercepterlistPermissionURLs,第一個方法的作用是判斷一個url是否要驗證,如果許可權表中有這個url,則需要進行授權,如果沒有則直接放行,listPermissionURLs是判斷一個使用者有權訪問的所有url

    @Override
      public boolean needInterceptor(String requestURI) {
      	// TODO Auto-generated method stub
      	List<Permission> permissionList = permissionDao.listPermission();
      	for(Permission p : permissionList) {
      		if(p.getUrl().equals(requestURI)) {
      			return true;
      		}
      	}
      	return false;
      }
    
      @Override
      public Set<String> listPermissionURLs(String userName) {
      	// TODO Auto-generated method stub
      	Set<String> result = new HashSet<>();
      	List<Permission> permissions = permissionDao.queryPermissionByUsername(userName);
      	for(Permission p : permissions) {
      		result.add(p.getUrl());
      	}
      	return result;
      }
    複製程式碼

    於是,在過濾器和Realm中就可以呼叫這兩個方法判斷是否需要授權和進行授權了。 過濾器類:

    public class URLPathMatchingFilter extends PathMatchingFilter {
      @Autowired
      PermissionService permissionService;
    
      @Override
      protected boolean onPreHandle(ServletRequest request,ServletResponse response,Object mappedValue)
          throws Exception {
        String requestURI = getPathWithinApplication(request);
    
        System.out.println("requestURI:" + requestURI);
    
        Subject subject = SecurityUtils.getSubject();
        // 如果沒有登入,就跳轉到登入頁面
        if (!subject.isAuthenticated()) {
          WebUtils.issueRedirect(request,response,"/login");
          return false;
        }
    
        // 看看這個路徑許可權裡有沒有維護,如果沒有維護,一律放行(也可以改為一律不放行)
        boolean needInterceptor = permissionService.needInterceptor(requestURI);
        if (!needInterceptor) {
          return true;
        } else {
          //如果有維護判斷是否有許可權訪問(進入Realm進行授權)
          if (subject.isPermitted(requestURI))
            return true;
          else {
            UnauthorizedException ex = new UnauthorizedException("當前使用者沒有訪問路徑 " + requestURI + " 的許可權");
    
            subject.getSession().setAttribute("ex",ex);
    
            WebUtils.issueRedirect(request,"/unauthorized");
            return false;
          }
        }
      }
    }
    複製程式碼
  • 最後執行結果

在執行最後結果之前,先明確一下資料表中使用者的角色與許可權。

許可權關係

所以執行結果如下:

  • 登入Object

登入Object

  • Object登入成功

Object登入成功

  • Object訪問addBlog

Object訪問增加部落格

  • Object訪問readBlog

Object訪問閱讀部落格

  • 登入Reader

登入Reader

  • Reader訪問addBlog

Reader訪問增加部落格

  • Reader訪問readBlog

Reader訪問閱讀部落格

結語

兩天時間學完Shiro並寫完了自己的學習筆記,從網上找資料學習到遇到各種坑,到差點偏離Shiro的方向,好在還是完成了Shiro的修煉,總算理解了這一系列驗證及授權的流程,之後應該將Shiro結合專案使用,並去理解其原理和工作流程。

參考資料:Shiro系列教材

歡迎大家訪問我的個人部落格:Object's Blog