Spring Security 使用總結

暑假的時候在學習了 Spring Security 并成功運用到了項目中。 在實踐中摸索出了一套結合 json + jwt(json web token) + Spring Boot + Spring Security 技術的權限方案趁著國慶假期記錄一下。

以下所有步驟的源碼可以從我的 github 上取得。如果要了解,請閱讀 readme.md。

各個技術的簡要介紹

json : 與前端交互的數(shù)據(jù)交換格式

個人理解上,它的特點是可以促進 web 前后端解耦,提升團隊的工作效率。 同時也是跟安卓端和 iOS 端交互的工具,目前是沒想出除了 json 和 XML 之外的交流形式誒(或許等以后有空閑時間會看看)。

它的另一個特點是輕量級,簡潔和清晰的層次可以方便我們閱讀和編寫,并且減少服務器帶寬占用。

jwt (json web token)

用人話講就是將用戶的身份信息(賬號名字)、其他信息(不固定,根據(jù)需要增加)在用戶登陸時提取出來,并且通過加密手段加工成一串密文,在用戶登陸成功時帶在返回結果發(fā)送給用戶。以后用戶每次請求時均帶上這串密文,服務器根據(jù)解析這段密文判斷用戶是否有權限訪問相關資源,并返回相應結果。

從網(wǎng)上摘錄了一些優(yōu)點,關于 jwt 的更多資料感興趣的讀者可以自行谷歌:

  1. 相比于session,它無需保存在服務器,不占用服務器內(nèi)存開銷。
  2. 無狀態(tài)、可拓展性強:比如有3臺機器(A、B、C)組成服務器集群,若session存在機器A上,session只能保存在其中一臺服務器,此時你便不能訪問機器B、C,因為B、C上沒有存放該Session,而使用token就能夠驗證用戶請求合法性,并且我再加幾臺機器也沒事,所以可拓展性好就是這個意思。
  3. 由 2 知,這樣做可就支持了跨域訪問。

Spring Boot

Spring Boot 是一個用來簡化 Spring 應用的搭建以及開發(fā)過程的框架。用完后會讓你大呼 : "wocao! 怎么有這么方便的東西! mama 再也不用擔心我不會配置 xml 配置文件了!"。

Spring Security

這是 Spring Security 提供的一個安全權限控制框架,可以根據(jù)使用者的需要定制相關的角色身份和身份所具有的權限,完成黑名單操作、攔截無權限的操作。配合 Spring Boot 可以快速開發(fā)出一套完善的權限系統(tǒng)。

本次技術方案中 Spring Security 執(zhí)行流程

本次技術方案中 Spring Security 執(zhí)行流程

從圖中可以看出本次執(zhí)行流程圍繞著的就是 token。

用戶通過登陸操作獲得我們返回的 token 并保存在本地。在以后每次請求都在請求頭中帶上 token ,服務器在收到客戶端傳來的請求時會判斷是否有 token ,若有,解析 token 并寫入權限到本次會話,若無直接跳過解析 token 的步驟,然后判斷本次訪問的接口是否需要認證,是否需要相應的權限,并根據(jù)本次會話中的認證情況做出反應。

動手實現(xiàn)這個安全框架

步驟一 : 建立項目,配置好數(shù)據(jù)源

  1. 使用 Itellij Idea 建立一個 Spring Boot 項目
需要的組件

選擇 Web 、Security 、 Mybatis 和 JDBC 四個組件。

  1. 在數(shù)據(jù)庫中建立所需的數(shù)據(jù)庫 spring_security
建立數(shù)據(jù)庫
  1. 在 spring boot 配置文件 application.properties 中配置好數(shù)據(jù)源
配置數(shù)據(jù)源
  1. 啟動項目查看 Spring Boot 是否替我們配置好 Spring Security 了。
啟動項目

若是正確啟動了,可以看到 Spring Security 生成了一段默認密碼。

我們訪問 localhost:8080 會彈出一個 basic 認證框

輸入 用戶名 user 密碼 前面自動生成的密碼 便可得到通過的返回消息(返回 404,因為我們還未建立任何頁面)
輸入 錯誤的用戶名或者密碼會返回 401 ,提示未認證

如果你走到了這一步,意味著你已經(jīng)配置好了所需要的環(huán)境,接下來就跟著進入下一步吧!

步驟二 : 生成我們的 jwt

在這一步我們將學習如何根據(jù)我們的需要生成我們定制的 token !

  1. 關閉 Spring Boot 替我們配置好的 Spring Security。(因為默認配置好的 Spring Security 會攔截掉我們定制的登陸接口)

創(chuàng)建 Spring Security 配置類 WebSecurityConfig.java

@Configuration      // 聲明為配置類
@EnableWebSecurity      // 啟用 Spring Security web 安全的功能
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().permitAll()       // 允許所有請求通過
                .and()
                .csrf()
                .disable()                      // 禁用 Spring Security 自帶的跨域處理
                .sessionManagement()                        // 定制我們自己的 session 策略
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 調(diào)整為讓 Spring Security 不創(chuàng)建和使用 session
    }
}
  1. 在數(shù)據(jù)庫中建立相應的用戶和角色。

創(chuàng)建用戶表 user

user 表

其中各個屬性和作用如下:

  • username : 用戶名
  • password : 密碼
  • role_id : 用戶所屬角色編號
  • last_password_change : 最后一次密碼修改時間
  • enable : 是否啟用該賬號,可以用來做黑名單

創(chuàng)建角色表 role

role 表

其中各個屬性作用如下:

  • role_id : 角色相應 id
  • role_name : 角色的名稱
  • auth : 角色所擁有的權限
  1. 編寫相應的登陸密碼判斷邏輯

因為登陸功能很容易實現(xiàn),這里就不寫出來占地方了哎。

  1. 編寫 token 操作類(生成 token 部分)

因為網(wǎng)上有造好的輪子,我們可以直接拿來做些修改就可以使用了。

使用 maven 導入網(wǎng)上造好的 jwt 輪子

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.4</version>
</dependency>

建立我們自己的 token 操作類 TokenUtils.java

public class TokenUtils {

    private final Logger logger = Logger.getLogger(this.getClass());

    @Value("${token.secret}")
    private String secret;

    @Value("${token.expiration}")
    private Long expiration;

    /**
     * 根據(jù) TokenDetail 生成 Token
     *
     * @param tokenDetail
     * @return
     */
    public String generateToken(TokenDetail tokenDetail) {
        Map<String, Object> claims = new HashMap<String, Object>();
        claims.put("sub", tokenDetail.getUsername());
        claims.put("created", this.generateCurrentDate());
        return this.generateToken(claims);
    }

    /**
     * 根據(jù) claims 生成 Token
     *
     * @param claims
     * @return
     */
    private String generateToken(Map<String, Object> claims) {
        try {
            return Jwts.builder()
                    .setClaims(claims)
                    .setExpiration(this.generateExpirationDate())
                    .signWith(SignatureAlgorithm.HS512, this.secret.getBytes("UTF-8"))
                    .compact();
        } catch (UnsupportedEncodingException ex) {
            //didn't want to have this method throw the exception, would rather log it and sign the token like it was before
            logger.warn(ex.getMessage());
            return Jwts.builder()
                    .setClaims(claims)
                    .setExpiration(this.generateExpirationDate())
                    .signWith(SignatureAlgorithm.HS512, this.secret)
                    .compact();
        }
    }

    /**
     * token 過期時間
     *
     * @return
     */
    private Date generateExpirationDate() {
        return new Date(System.currentTimeMillis() + this.expiration * 1000);
    }

    /**
     * 獲得當前時間
     *
     * @return
     */
    private Date generateCurrentDate() {
        return new Date(System.currentTimeMillis());
    }

}

這個工具類的目前做的事情是 :

  • 把用戶名封裝進下載的輪子的 token 的主體 claims 中,并在里面封裝了當前時間(方便后面判斷 token 是否在修改密碼之前生成的)
  • 再計算 token 過期的時間寫入到 輪子的 token 中
  • 對 輪子的 token 進行撒鹽加密,生成一串字符串,即我們定制的 token

生成定制 token 的方法的入?yún)?TokenDetail 的定義如下

public interface TokenDetail {

    //TODO: 這里封裝了一層,不直接使用 username 做參數(shù)的原因是可以方便未來增加其他要封裝到 token 中的信息

    String getUsername();
}

public class TokenDetailImpl implements TokenDetail {

    private final String username;

    public TokenDetailImpl(String username) {
        this.username = username;
    }

    @Override
    public String getUsername() {
        return this.username;
    }
}

同時這個工具類把加密 token 撒鹽的字符串和 token 的過期時間提取到了 application.properties 中

# token 加密密鑰
token.secret=secret
# token 過期時間,以秒為單位,604800 是 一星期
token.expiration=604800
  1. 至此,我們生成 token 的教程已經(jīng)完成,至于登陸接口,判斷賬號密碼是否正確的操作就留給讀者去實現(xiàn),讀者只需在登陸成功時在結果中返回生成好的 token 給用戶即可。

步驟三 : 實現(xiàn)驗證 token 是否有效,并根據(jù) token 獲得賬號詳細信息(權限,是否處于封號狀態(tài))的功能

  1. 分析實現(xiàn)的過程

在步驟二中,我們把用戶的的 username 、 token 創(chuàng)建的時間 、 token 過期的時間封裝到了加密過后的 token 字符串中,就是為了服務此時我們驗證用戶權限的目的。

假設我們此時拿到了用戶傳遞過來的一串 token,并且要根據(jù)這串 token 獲得用戶的詳情可以這樣做:

A. 嘗試解析這串 token ,若成功解析出來,進入下一步,否則終止解析過程
B. 根據(jù)解析出來的 username 從數(shù)據(jù)庫中查找用戶的賬號,最后一次密碼修改的時間,權限,是否封號等用戶詳情信息,把這些信息封裝到一個實體類中(userDetail類)。若查找不到該用戶,終止解析進程
C. 檢查 userDetail 中記錄的封號狀態(tài),若是賬號已被封號,返回封號結果,終止請求
D. 根據(jù) userDtail 比較 token 是否處于有效期內(nèi),若不處于有效期內(nèi),終止解析過程,否則繼續(xù)
E. 將 userDetail 中記錄的用戶權限寫入本次請求會話中,解析完成。

可參考下圖理解:

分析解析 token ,檢查權限的過程

下面開始動手實現(xiàn)

  1. 嘗試解析 token 獲得 username
/**
     * 從 token 中拿到 username
     *
     * @param token
     * @return
     */
    public String getUsernameFromToken(String token) {
        String username;
        try {
            final Claims claims = this.getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 解析 token 的主體 Claims
     *
     * @param token
     * @return
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser()
                    .setSigningKey(this.secret.getBytes("UTF-8"))
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

在這段代碼中,我們先對 token 進行解密,獲得 token 中封裝好的主體部分 claims (前面第二部引入的 別人造好的輪子),然后嘗試獲得里面封裝的 username 字符串。

  1. 從數(shù)據(jù)庫中獲得用戶詳情 userDetail

這里我們將實現(xiàn) Spring Security 的一個 UserDetailService 接口,這個接口只有一個方法, loadUserByUsername。流程圖如下

獲得 UserDetail 的流程圖

代碼如下:

public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    /**
     * 獲取 userDetail
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User user = this.userMapper.getUserFromDatabase(username);

        if (user == null) {
            throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username));
        } else {
            return SecurityModelFactory.create(user);
        }
    }
}

public class User implements LoginDetail, TokenDetail {

    private String username;
    private String password;
    private String authorities;
    private Long lastPasswordChange;
    private char enable;

    // 省略構造器和 getter setter 方法
}

public class SecurityModelFactory {

    public static UserDetailImpl create(User user) {
        Collection<? extends GrantedAuthority> authorities;
        try {
            authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(user.getAuthorities());
        } catch (Exception e) {
            authorities = null;
        }

        Date lastPasswordReset = new Date();
        lastPasswordReset.setTime(user.getLastPasswordChange());
        return new UserDetailImpl(
                user.getUsername(),
                user.getUsername(),
                user.getPassword(),
                lastPasswordReset,
                authorities,
                user.enable()
        );
    }

}

其中獲得未處理過的用戶詳細信息 User 類的 mapper 類定義如下:

public interface UserMapper {


    User getUserFromDatabase(@Param("username") String username);

}

相應的 xml 文件為 :

<select id="getUserFromDatabase"  resultMap="getUserFromDatabaseMap">
        SELECT
        `user`.username,
        `user`.`password`,
        `user`.role_id,
        `user`.enable,
        `user`.last_password_change,
        `user`.enable,
        role.auth
        FROM
        `user` ,
        role
        WHERE
        `user`.role_id = role.role_id AND
        `user`.username = #{username}
    </select>

    <resultMap id="getUserFromDatabaseMap" type="cn.ssd.wean2016.springsecurity.model.domain.User">
        <id column="username" property="username"/>
        <result column="password" property="password"/>
        <result column="last_password_change" property="lastPasswordChange"/>
        <result column="auth" property="authorities"/>
        <result column="enable" property="enable"/>
    </resultMap>

至此,我們已經(jīng)完成獲取用戶詳細信息的的功能了。接下來只要限制接口的訪問權限,并要求用戶訪問接口時帶上 token 即可實現(xiàn)對權限的控制。

步驟四 : 定義解析 token 的攔截器

老規(guī)矩,上流程圖:

解析 token 的攔截器的執(zhí)行流程

下面定義這個攔截器

public class AuthenticationTokenFilter extends UsernamePasswordAuthenticationFilter {

    /**
     * json web token 在請求頭的名字
     */
    @Value("${token.header}")
    private String tokenHeader;

    /**
     * 輔助操作 token 的工具類
     */
    @Autowired
    private TokenUtils tokenUtils;

    /**
     * Spring Security 的核心操作服務類
     * 在當前類中將使用 UserDetailsService 來獲取 userDetails 對象
     */
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 將 ServletRequest 轉換為 HttpServletRequest 才能拿到請求頭中的 token
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        // 嘗試獲取請求頭的 token
        String authToken = httpRequest.getHeader(this.tokenHeader);
        // 嘗試拿 token 中的 username
        // 若是沒有 token 或者拿 username 時出現(xiàn)異常,那么 username 為 null
        String username = this.tokenUtils.getUsernameFromToken(authToken);

        // 如果上面解析 token 成功并且拿到了 username 并且本次會話的權限還未被寫入
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            // 用 UserDetailsService 從數(shù)據(jù)庫中拿到用戶的 UserDetails 類
            // UserDetails 類是 Spring Security 用于保存用戶權限的實體類
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
            // 檢查用戶帶來的 token 是否有效
            // 包括 token 和 userDetails 中用戶名是否一樣, token 是否過期, token 生成時間是否在最后一次密碼修改時間之前
            // 若是檢查通過
            if (this.tokenUtils.validateToken(authToken, userDetails)) {
                // 生成通過認證
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
                // 將權限寫入本次會話
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            if (!userDetails.isEnabled()){
                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().print("{\"code\":\"452\",\"data\":\"\",\"message\":\"賬號處于黑名單\"}");
                return;
            }
        }
        chain.doFilter(request, response);
    }

}

其中檢查 token 是否有效的 tokenUtils.validateToken(authToken, userDetails) 方法定義如下:

/**
     * 檢查 token 是否處于有效期內(nèi)
     * @param token
     * @param userDetails
     * @return
     */
    public Boolean validateToken(String token, UserDetails userDetails) {
        UserDetailImpl user = (UserDetailImpl) userDetails;
        final String username = this.getUsernameFromToken(token);
        final Date created = this.getCreatedDateFromToken(token);
        return (username.equals(user.getUsername()) && !(this.isTokenExpired(token)) && !(this.isCreatedBeforeLastPasswordReset(created, user.getLastPasswordReset())));
    }

    /**
     * 獲得我們封裝在 token 中的 token 創(chuàng)建時間
     * @param token
     * @return
     */
    public Date getCreatedDateFromToken(String token) {
        Date created;
        try {
            final Claims claims = this.getClaimsFromToken(token);
            created = new Date((Long) claims.get("created"));
        } catch (Exception e) {
            created = null;
        }
        return created;
    }

    /**
     * 獲得我們封裝在 token 中的 token 過期時間
     * @param token
     * @return
     */
    public Date getExpirationDateFromToken(String token) {
        Date expiration;
        try {
            final Claims claims = this.getClaimsFromToken(token);
            expiration = claims.getExpiration();
        } catch (Exception e) {
            expiration = null;
        }
        return expiration;
    }

    /**
     * 檢查當前時間是否在封裝在 token 中的過期時間之后,若是,則判定為 token 過期
     * @param token
     * @return
     */
    private Boolean isTokenExpired(String token) {
        final Date expiration = this.getExpirationDateFromToken(token);
        return expiration.before(this.generateCurrentDate());
    }

    /**
     * 檢查 token 是否是在最后一次修改密碼之前創(chuàng)建的(賬號修改密碼之后之前生成的 token 即使沒過期也判斷為無效)
     * @param created
     * @param lastPasswordReset
     * @return
     */
    private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
        return (lastPasswordReset != null && created.before(lastPasswordReset));
    }

步驟五 : 注冊步驟四的攔截器,使它在 Spring Security 讀取本次會話權限前將用戶所具有的權限寫入本次會話中

在 SpringSecurity 的配置類 WebSecurityConfig.java 中添加如下配置

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 注冊 token 轉換攔截器為 bean
     * 如果客戶端傳來了 token ,那么通過攔截器解析 token 賦予用戶權限
     *
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationTokenFilter authenticationTokenFilterBean() throws Exception {
        AuthenticationTokenFilter authenticationTokenFilter = new AuthenticationTokenFilter();
        authenticationTokenFilter.setAuthenticationManager(authenticationManagerBean());
        return authenticationTokenFilter;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/auth").authenticated()       // 需攜帶有效 token
                .antMatchers("/admin").hasAuthority("admin")   // 需擁有 admin 這個權限
                .antMatchers("/ADMIN").hasRole("ADMIN")     // 需擁有 ADMIN 這個身份
                .anyRequest().permitAll()       // 允許所有請求通過
                .and()
                .csrf()
                .disable()                      // 禁用 Spring Security 自帶的跨域處理
                .sessionManagement()                        // 定制我們自己的 session 策略
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 調(diào)整為讓 Spring Security 不創(chuàng)建和使用 session


        /**
         * 本次 json web token 權限控制的核心配置部分
         * 在 Spring Security 開始判斷本次會話是否有權限時的前一瞬間
         * 通過添加過濾器將 token 解析,將用戶所有的權限寫入本次 Spring Security 的會話
         */
        http
                .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class);
    }
}

其中我們將步驟四中定義的攔截器注冊到 Spring 中成為一個 bean ,并登記在 Spring Security 開始判斷本次會話是否有權限時的前一瞬間通過添加過濾器將 token 解析,將用戶所有的權限寫入本次會話。

其次,我們添加了三個 ant 風格的地址攔截規(guī)則 :

  • /auth : 要求攜帶有效的 token
  • /admin : 要求攜帶 token 所對應的賬號具有 admin 這個權限
  • /ADMIN : 要求攜帶 token 對應的張賬號具有 ROLE_ADMIN 這個身份

啟動程序到 8080 端口,通過 /login 接口登陸 guest 賬號,對 /auth 接口嘗試訪問,結果如下 :

對 auth 的訪問結果

顯然,因為 token 有效,所以成功通過了攔截

接下來嘗試訪問 /admin 接口,結果如下 :

對 admin 的訪問結果

顯然,因為攜帶的 token 不具有 admin 這個權限,所以請求被攔截攔截

至此,我們已經(jīng)完成了一套權限簡單的權限規(guī)則系統(tǒng),在下一步中,我們將對無權限訪問的返回結果進行優(yōu)化,并結束這次總結。

步驟六 : 完善 401 和 403 返回結果

定義 401 處理器,實現(xiàn) AuthenticationEntryPoint 接口

public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {

  /**
   * 未登錄或無權限時觸發(fā)的操作
   * 返回  {"code":401,"message":"小弟弟,你沒有攜帶 token 或者 token 無效!","data":""}
   * @param httpServletRequest
   * @param httpServletResponse
   * @param e
   * @throws IOException
   * @throws ServletException
   */
  @Override
  public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
      //返回json形式的錯誤信息
      httpServletResponse.setCharacterEncoding("UTF-8");
      httpServletResponse.setContentType("application/json");

      httpServletResponse.getWriter().println("{\"code\":401,\"message\":\"小弟弟,你沒有攜帶 token 或者 token 無效!\",\"data\":\"\"}");
      httpServletResponse.getWriter().flush();
  }

}

定義 403 處理器,實現(xiàn) AccessDeniedHandler 接口

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        //返回json形式的錯誤信息
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json");

        httpServletResponse.getWriter().println("{\"code\":403,\"message\":\"小弟弟,你沒有權限訪問呀!\",\"data\":\"\"}");
        httpServletResponse.getWriter().flush();
    }
}

將這兩個處理器配置到 SpringSecurity 的配置類中 :

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 注冊 401 處理器
     */
    @Autowired
    private EntryPointUnauthorizedHandler unauthorizedHandler;

    /**
     * 注冊 403 處理器
     */
    @Autowired
    private MyAccessDeniedHandler accessDeniedHandler;

    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                ...

                // 配置被攔截時的處理
                .exceptionHandling()
                .authenticationEntryPoint(this.unauthorizedHandler)   // 添加 token 無效或者沒有攜帶 token 時的處理
                .accessDeniedHandler(this.accessDeniedHandler)      //添加無權限時的處理

                ...
    }
}

嘗試以 guest 的身份訪問 /admin 接口,結果如下:

以 guest 身份訪問 admin 接口

嘻嘻,顯然任務完成啦?。。。ㄟ@個接口也可以用 lamda 表達式配置,這個留給大家去探索啦~~~)

溜了溜了……

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容