【Spring】12、Spring Security 四种使用方式
spring security使用分类:
如何使用spring security,相信百度过的都知道,总共有四种用法,从简到深为:1、不用数据库,全部数据写在配置文件,这个也是官方文档里面的demo;2、使用数据库,根据spring security默认实现代码设计数据库,也就是说数据库已经固定了,这种方法不灵活,而且那个数据库设计得很简陋,实用性差;3、spring security和Acegi不同,它不能修改默认filter了,但支持插入filter,所以根据这个,我们可以插入自己的filter来灵活使用;4、暴力手段,修改源码,前面说的修改默认filter只是修改配置文件以替换filter而已,这种是直接改了里面的源码,但是这种不符合OO设计原则,而且不实际,不可用。
本文面向读者:
因为本文准备介绍第三种方法,所以面向的读者是已经具备了spring security基础知识的。不过不要紧,读者可以先看一下这个教程,看完应该可以使用第二种方法开发了。
spring security的简单原理:
使用众多的拦截器对url拦截,以此来管理权限。但是这么多拦截器,笔者不可能对其一一来讲,主要讲里面核心流程的两个。 首先,权限管理离不开登陆验证的,所以登陆验证拦截器AuthenticationProcessingFilter要讲; 还有就是对访问的资源管理吧,所以资源管理拦截器AbstractSecurityInterceptor要讲; 但拦截器里面的实现需要一些组件来实现,所以就有了AuthenticationManager、accessDecisionManager等组件来支撑。 现在先大概过一遍整个流程,用户登陆,会被AuthenticationProcessingFilter拦截,调用AuthenticationManager的实现,而且AuthenticationManager会调用ProviderManager来获取用户验证信息(不同的Provider调用的服务不同,因为这些信息可以是在数据库上,可以是在LDAP服务器上,可以是xml配置文件上等),如果验证通过后会将用户的权限信息封装一个User放到spring的全局缓存SecurityContextHolder中,以备后面访问资源时使用。 访问资源(即授权管理),访问url时,会通过AbstractSecurityInterceptor拦截器拦截,其中会调用FilterInvocationSecurityMetadataSource的方法来获取被拦截url所需的全部权限,在调用授权管理器AccessDecisionManager,这个授权管理器会通过spring的全局缓存SecurityContextHolder获取用户的权限信息,还会获取被拦截的url和被拦截url所需的全部权限,然后根据所配的策略(有:一票决定,一票否定,少数服从多数等),如果权限足够,则返回,权限不够则报错并调用权限不足页面。 虽然讲得好像好复杂,读者们可能有点晕,不过不打紧,真正通过代码的讲解在后面,读者可以看完后面的代码实现,再返回看这个简单的原理,可能会有不错的收获。
spring security使用实现(基于spring security3.1.4):
javaEE的入口:web.xml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
<!--?xml "1.0"
"UTF-8" ?--> <web-app "2.5"
"http://java.sun.com/xml/ns/javaee"
"http://www.w3.org/2001/XMLSchema-instance"
"http://java.sun.com/xml/ns/javaee > <!--加载Spring <context-param> <param-name>contextConfigLocation</param-name> <param-value> </context-param> <!-- 1 的过滤器链配置 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter- class >org.springframework.web.filter.DelegatingFilterProxy</filter- class > </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- <listener> <listener- class >org.springframework.web.context.ContextLoaderListener</listener- class > </listener> <!--系统欢迎页面 <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app> |
上面那个配置不用多说了吧 直接上spring security的配置文件securityConfig.xml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
<!--?xml "1.0"
"UTF-8" ?--> <b:beans "http://www.springframework.org/schema/security"
"http://www.springframework.org/schema/beans"
"http://www.w3.org/2001/XMLSchema-instance"
3.0 .xsd http: //www.springframework.org/schema/security <!--登录页面不过滤 <http "/login.jsp"
"none" > <http "/accessDenied.jsp" > <form-login "/login.jsp" > <!--访问/http: //blog.csdn.net/u012367513/article/details/admin.jsp资源的用户必须具有ROLE_ADMIN的权限 <!-- "/http://blog.csdn.net/u012367513/article/details/admin.jsp"
"ROLE_ADMIN"
<!--访问/**资源的用户必须具有ROLE_USER的权限 <!-- "/**"
"ROLE_USER"
<session-management> <concurrency-control "1"
if -maximum-exceeded= "false" > </concurrency-control></session-management> <!--增加一个filter,这点与 <custom-filter "myFilter"
"FILTER_SECURITY_INTERCEPTOR" > </custom-filter></form-login></http> <!--一个自定义的filter,必须包含 我们的所有控制将在这三个类中实现,解释详见具体配置 <b:bean "myFilter"
= "com.erdangjiade.spring.security.MyFilterSecurityInterceptor" > <b:property "authenticationManager"
"authenticationManager" > <b:property "accessDecisionManager"
"myAccessDecisionManagerBean" > <b:property "securityMetadataSource"
"securityMetadataSource" > </b:property></b:property></b:property></b:bean> <!--验证配置,认证管理器,实现用户认证的入口,主要实现UserDetailsService接口即可 <!--如果用户的密码采用加密的话 "md5"
</authentication-provider> </authentication-manager> <!--在这个类中,你就可以从数据库中读入用户的密码,角色信息,是否锁定,账号是否过期等 <b:bean "myUserDetailService"
= "com.erdangjiade.spring.security.MyUserDetailService" > <!--访问决策器,决定某个用户具有的角色,是否有足够的权限去访问某个资源 <b:bean "myAccessDecisionManagerBean"
= "com.erdangjiade.spring.security.MyAccessDecisionManager" > </b:bean> <!--资源源数据定义,将所有的资源和权限对应关系建立起来,即定义某一资源可以被哪些角色访问 <b:bean "securityMetadataSource"
= "com.erdangjiade.spring.security.MyInvocationSecurityMetadataSource" > </b:bean></b:bean></http></b:beans> |
其实所有配置都在里面,首先这个版本的spring security不支持了filter=none的配置了,改成了独立的,里面你可以配登陆页面、权限不足的返回页面、注销页面等,上面那些配置,我注销了一些资源和权限的对应关系,笔者这里不需要在这配死它,可以自己写拦截器来获得资源与权限的对应关系。 session-management是用来防止多个用户同时登陆一个账号的。
最重要的是笔者自己写的拦截器myFilter(终于讲到重点了),首先这个拦截器会加载在FILTER_SECURITY_INTERCEPTOR之前(配置文件上有说),最主要的是这个拦截器里面配了三个处理类,第一个是authenticationManager,这个是处理验证的,这里需要特别说明的是:这个类不单只这个拦截器用到,还有验证拦截器AuthenticationProcessingFilter也用到 了,而且实际上的登陆验证也是AuthenticationProcessingFilter拦截器调用authenticationManager来处理的,我们这个拦截器只是为了拿到验证用户信息而已(这里不太清楚,因为authenticationManager笔者设了断点,用户登陆后再也没调用这个类了,而且调用这个类时不是笔者自己写的那个拦截器调用的,看了spring技术内幕这本书才知道是AuthenticationProcessingFilter拦截器调用的)。 securityMetadataSource这个用来加载资源与权限的全部对应关系的,并提供一个通过资源获取所有权限的方法。
accessDecisionManager这个也称为授权器,通过登录用户的权限信息、资源、获取资源所需的权限来根据不同的授权策略来判断用户是否有权限访问资源。
authenticationManager类可以有许多provider(提供者)提供用户验证信息,这里笔者自己写了一个类myUserDetailService来获取用户信息。
MyUserDetailService:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
package
import
import
import
import
import
import
import
import
import
public
implements
//登陆验证时,通过username获取用户的所有权限信息, //并返回User放到spring的全局缓存SecurityContextHolder中,以供授权器使用 public
throws
Collection<grantedauthority> new
GrantedAuthorityImpl new
"ROLE_ADMIN" ); GrantedAuthorityImpl new
"ROLE_USER" ); if (username.equals( "lcy" )){ auths= new
auths.add(auth1); auths.add(auth2); } User new
"lcy" , true , true , true , true , return
} } |
其中UserDetailsService接口是spring提供的,必须实现的。别看这个类只有一个方法,而且这么简单,其中内涵玄机。 读者看到这里可能就大感疑惑了,不是说好的用数据库吗?对,但别急,等笔者慢慢给你们解析。 首先,笔者为什么不用数据库,还不是为了读者们测试方便,并简化spring security的流程,让读者抓住主线,而不是还要烦其他事(导入数据库,配置数据库,写dao等)。 这里笔者只是用几个数据模拟了从数据库中拿到的数据,也就是说ROLE_ADMIN、ROLE_USER、lcy(第一个是登陆账号)、lcy(第二个是密码)是从数据库拿出来的,这个不难实现吧,如果需要数据库时,读者可以用自己写的dao通过参数username来查询出这个用户的权限信息(或是角色信息,就是那个ROLE_*,对必须是ROLE_开头的,不然spring security不认账的,其实是spring security里面做了一个判断,必须要ROLE_开头,读者可以百度改一下),再返回spring自带的数据模型User即可。 这个写应该比较清晰、灵活吧,总之数据读者们通过什么方法获取都行,只要返回一个User对象就行了。(这也是笔者为什么要重写这个类的原因)
通过MyUserDetailService拿到用户信息后,authenticationManager对比用户的密码(即验证用户),然后这个AuthenticationProcessingFilter拦截器就过咯。
下面要说的是另外一个拦截器,就是笔者自己写的拦截器MyFilterSecurityInterceptor:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
package
import
import
import
import
import
import
import
import
import
import
import
import
public
extends
implements
//配置文件注入 private
//登陆后,每次访问资源都通过这个拦截器拦截 public
throws
FilterInvocation new
invoke(fi); } public
return
.securityMetadataSource; } public
extends
return
class ; } public
throws
//fi里面有一个被拦截的url //里面调用MyInvocationSecurityMetadataSource的getAttributes(Object //再调用MyAccessDecisionManager的decide方法来校验用户的权限是否足够 InterceptorStatusToken super .beforeInvocation(fi); try
//执行下一个拦截器 fi.getChain().doFilter(fi.getRequest(), } finally
super .afterInvocation(token, null ); } } public
return
.securityMetadataSource; } public
FilterInvocationSecurityMetadataSource { this .securityMetadataSource } public
} public
throws
} } |
继承AbstractSecurityInterceptor、实现Filter是必须的。 首先,登陆后,每次访问资源都会被这个拦截器拦截,会执行doFilter这个方法,这个方法调用了invoke方法,其中fi断点显示是一个url(可能重写了toString方法吧,但是里面还有一些方法的),最重要的是beforeInvocation这个方法,它首先会调用MyInvocationSecurityMetadataSource类的getAttributes方法获取被拦截url所需的权限,在调用MyAccessDecisionManager类decide方法判断用户是否够权限。弄完这一切就会执行下一个拦截器。
再看一下这个MyInvocationSecurityMetadataSource的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
package
import
import
import
import
import
import
import
import
import
import
import
public
implements
private
new
private
"" >> null ; //tomcat启动时实例化一次 public
loadResourceDefine(); } //tomcat开启时加载一次,加载所有url和权限(或角色)的对应关系 private
resourceMap new
"" >>(); Collection<configattribute> new
ConfigAttribute new
"ROLE_USER" ); atts.add(ca); resourceMap.put( "/index.jsp" , Collection<configattribute> new
ConfigAttribute new
"ROLE_NO" ); attsno.add(cano); } //参数是要访问的url,返回这个url对于的所有权限(或角色) public
throws
// String Iterator<string>ite while
String if
return
} } return
; } public
return
; } public
return
; } } </configattribute></string></configattribute></configattribute></configattribute></configattribute></configattribute></string,></string,> |
实现FilterInvocationSecurityMetadataSource接口也是必须的。 首先,这里也是模拟了从数据库中获取信息。 其中loadResourceDefine方法不是必须的,这个只是加载所有的资源与权限的对应关系并缓存起来,避免每次获取权限都访问数据库(提高性能),然后getAttributes根据参数(被拦截url)返回权限集合。 这种缓存的实现其实有一个缺点,因为loadResourceDefine方法是放在构造器上调用的,而这个类的实例化只在web服务器启动时调用一次,那就是说loadResourceDefine方法只会调用一次,如果资源和权限的对应关系在启动后发生了改变,那么缓存起来的就是脏数据,而笔者这里使用的就是缓存数据,那就会授权错误了。但如果资源和权限对应关系是不会改变的,这种方法性能会好很多。 现在说回有数据库的灵活实现,读者看到这,可能会说,这还不简单,和上面MyUserDetailService类一样使用dao灵活获取数据就行啦。 如果读者这样想,那只想到了一半,想一下spring的机制(依赖注入),dao需要依赖注入吧,但这是在启动时候,那个dao可能都还没加载,所以这里需要读者自己写sessionFactory,自己写hql或sql,对,就在loadResourceDefine方法里面写(这个应该会写吧,基础来的)。那如果说想用第二种方法呢(就是允许资源和权限的对应关系改变的那个),那更加简单,根本不需要loadResourceDefine方法了,直接在getAttributes方法里面调用dao(这个是加载完,后来才会调用的,所以可以使用dao),通过被拦截url获取数据库中的所有权限,封装成Collection返回就行了。(灵活、简单) 注意:接口UrlMatcher和实现类AntUrlPathMatcher是笔者自己写的,这本来是spring以前版本有的,现在没有了,但是觉得好用就用会来了,直接上代码(读者也可以自己写正则表达式验证被拦截url和缓存或数据库的url是否匹配):
1
2
3
4
5
6
7
8
|
package
public
Object boolean
String boolean
} |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
package
import
import
public
implements
private
private
public
this ( true ); } public
boolean
{ this .requiresLowerCaseUrl true ; this .pathMatcher new
this .requiresLowerCaseUrl } public
if
this .requiresLowerCaseUrl) return
} return
} public
boolean
this .requiresLowerCaseUrl } public
if
"/**" .equals(path)) "**" .equals(path))) return
; } return
.pathMatcher.match((String)path, } public
return "/**" ; } public
return
.requiresLowerCaseUrl; } public
return
.getClass().getName() "[requiresLowerCase='" + this .requiresLowerCaseUrl "']" ; } } |
然后MyAccessDecisionManager类的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
package
import
import
import
import
import
import
import
import
import
public
implements
//检查用户是否够权限访问资源 //参数authentication是从spring的全局缓存SecurityContextHolder中拿到的,里面是用户的权限信息 //参数object是url //参数configAttributes所需的权限 public
Collection<configattribute> throws
if (configAttributes null ){ return ; } Iterator<configattribute> while (ite.hasNext()){ ConfigAttribute String for (GrantedAuthority if (needRole.equals(ga.getAuthority())){ return ; } } } //注意:执行这里,后台是会抛异常的,但是界面会跳转到所配的access-denied-page页面 throw
"no ); } public
return
; } public
return
; } }</configattribute></configattribute> |
接口AccessDecisionManager也是必须实现的。 decide方法里面写的就是授权策略了,笔者的实现是,没有明说需要权限的(即没有对应的权限的资源),可以访问,用户具有其中一个或多个以上的权限的可以访问。这个就看需求了,需要什么策略,读者可以自己写其中的策略逻辑。通过就返回,不通过抛异常就行了,spring security会自动跳到权限不足页面(配置文件上配的)。
就这样,整个流程过了一遍。
剩下的页面代码
本来想给这个demo的源码出来的,但是笔者觉得,通过这个教程一步一步读下来,并自己敲一遍代码,会比直接运行一遍demo印象更深刻,并且更容易理解里面的原理。 而且我的源码其实都公布出来了: login.jsp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
<% @page
"java"
= "java.util.*"
"UTF-8" %> <title>登录</title> <form "j_spring_security_check"
"POST" > <table> <tbody><tr> <td>用户:</td> <td><input "'text'"
"'j_username'" ></td> </tr> <tr> <td>密码:</td> <td><input "'password'"
"'j_password'" ></td> </tr> <tr> <td><input "reset"
"reset" ></td> <td><input "submit"
"submit" ></td> </tr> </tbody></table> </form> |
index.jsp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
<% @page
"java"
= "java.util.*"
"UTF-8" %> <title>My JSP 'index.jsp' starting page</title> <h3>这是首页</h3>欢迎 <sec:authentication "name" > <br> 进入admin页面 进入其它页面 </sec:authentication> |
http://blog.csdn.net/u012367513/article/details/admin.jsp:
1
2
3
4
5
6
7
8
9
|
<% @page
"java"
= "java.util.*"
"utf-8" %> 欢迎来到管理员页面. <br> |
accessDenied.jsp:
1
2
3
4
5
6
7
8
9
|
<% @page
"java"
= "java.util.*"
"utf-8" %> 欢迎来到管理员页面. <br> |
http://blog.csdn.net/u012367513/article/details/other.jsp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
<%@ "java"
= "java.util.*"
"UTF-8" %> <% String String "://" +request.getServerName()+ ":" +request.getServerPort()+path+ "/" ; %> <base "<%=basePath%>" > <meta "pragma"
"no-cache" > <meta "cache-control"
"no-cache" > <meta "expires"
"0" > <meta "keywords"
"keyword1,keyword2,keyword3" > <meta "description"
"This > <!-- <link "stylesheet"
"text/css"
"styles.css" > --> <h3>这里是Other页面</h3> |
项目图:
<img src="http://www.2cto.com/uploadfile/Collfiles/20140829/20140829091240286.png" alt="n峨n竩�漽j喎�" http:="" www.2cto.com="" ym"="" target="_blank" class="keylink" style="border-width: 0px; padding: 0px; margin: 0px; list-style: none; width: 322px; height: 464px;">源码和jar包都在这个教程里面,为什么不直接给?笔者的目的是让读者跟着教程敲一遍代码,使印象深刻(相信做这行的都知道,同样一段代码,看过和敲过的区别是多么的大),所以不惜如此来强迫大家了。
转自:http://blog.csdn.net/u013516966/article/details/46688765
【Spring】12、Spring Security 四种使用方式的更多相关文章
- spring security四种实现方式
spring security四种实现方式 spring(20) > 目录(?)[+] 最简单配置spring-securityxml实现1 实现UserDetailsService 实现动态过 ...
- Spring中bean的四种注入方式
一.前言 最近在复习Spring的相关内容,这篇博客就来记录一下Spring为bean的属性注入值的四种方式.这篇博客主要讲解在xml文件中,如何为bean的属性注入值,最后也会简单提一下使用注解 ...
- Spring Data Jpa的四种查询方式
一.调用接口的方式 1.基本介绍 通过调用接口里的方法查询,需要我们自定义的接口继承Spring Data Jpa规定的接口 public interface UserDao extends JpaR ...
- Spring事务管理的四种方式(以银行转账为例)
Spring事务管理的四种方式(以银行转账为例) 一.事务的作用 将若干的数据库操作作为一个整体控制,一起成功或一起失败. 原子性:指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不 ...
- 读书笔记——spring cloud 中 HystrixCommand的四种执行方式简述
读了<Spring Cloud 微服务实战>第151-154页, 总结如下: Hystrix存在两种Command,一种是HystrixCommand,另一种是HystrixObserva ...
- 【spring springmvc】这里有你想要的SpringMVC的REST风格的四种请求方式
概述 之前的文章springmvc使用注解声明控制器与请求映射有简单提到过控制器与请求映射,这一次就详细讲解一下SpringMVC的REST风格的四种请求方式及其使用方法. 你能get的知识点 1.什 ...
- JAVA高级架构师基础功:Spring中AOP的两种代理方式:动态代理和CGLIB详解
在spring框架中使用了两种代理方式: 1.JDK自带的动态代理. 2.Spring框架自己提供的CGLIB的方式. 这两种也是Spring框架核心AOP的基础. 在详细讲解上述提到的动态代理和CG ...
- spring MVC 拦截有几种实现方式
spring MVC 拦截有几种实现方式 实现HandelInterceptor接口方式 继承HandelInterceptor 的方式.一般有这两种方式 spring 如何走单元测式 ...
- Android数据的四种存储方式SharedPreferences、SQLite、Content Provider和File (三) —— SharePreferences
除了SQLite数据库外,SharedPreferences也是一种轻型的数据存储方式,它的本质是基于XML文件存储key-value键值对数据,通常用来存储一些简单的配置信息.其存储位置在/data ...
随机推荐
- Django积木块二——邮箱
邮箱 django中自带的功能,因为登录注册都需要邮箱,因此新增了一个小的app--utils用来存放 # email_send.py import random from django.core.m ...
- 20155326刘美岑 《网络对抗》Exp2 后门原理与实践
20155326刘美岑 <网络对抗>Exp2 后门原理与实践 实验内容 (1)使用netcat获取主机操作Shell,cron启动 (2)使用socat获取主机操作Shell, 任务计划启 ...
- samba服务配置(二)
需求: 某公司销售部门提出一个文件共享需求,要求部门共享目录有三个,第一个共享目录所有销售部门人员都具有可读可写权限: 第二个共享目录所有销售人员只读权限,经理级别的销售人员具有可读可写权限:第三个共 ...
- sku 和 spu
https://www.jianshu.com/p/867429702d5a 里面的图片挺好的
- 在源文件(.c)和头文件(.h)中声明和定义的区别——C语言
最近在看多文件编程的时候遇到的一个问题,本来以为理解了声明和定义的区别(然而并没有····),也算是重新认识了一次声明和定义,下面上代码 情形一:在源文件(.c)中 相信大部分读者对声明和定义的理解是 ...
- Yii2 Template 组件框架集封装
项目简介: Yii2_Template是一个“提供大多数PHP常用的组件去集合成的一套基于Yii2的项目框架”. 该项目是一款秉着提高 开发效率.降低开发成本,遵循高拓展,高可用的原则的进行开发的框架 ...
- 机器学习基石笔记:12 Nonlinear Transformation
一.二次假设 实际上线性假设的模型复杂度是受到限制的, 需要高次假设打破这个限制. 假设数据不是线性可分的,但是可以被一个圆心在原点的圆分开, 需要我们重新设计基于该圆的PLA等算法吗? 不用, 只需 ...
- CSS 常用技巧
概述 相信大家在写css属性的时候,会遇到一些问题,比如说:垂直对齐,垂直居中,背景渐变动画,表格宽度自适应,模糊文本,样式重置,清除浮动,通用媒体查询,自定义选择文本,强制出现滚动条,固定头部和页脚 ...
- salesforce lightning零基础学习(十) Aura Js 浅谈三: $A、Action、Util篇
前两篇分别介绍了Component类以及Event类,此篇将会说一下 $A , Action以及 Util. 一. Action Action类通常用于和apex后台交互,设置参数,调用后台以及对结 ...
- 从零开始学 Web 之 BOM(二)定时器
大家好,这里是「 从零开始学 Web 系列教程 」,并在下列地址同步更新...... github:https://github.com/Daotin/Web 微信公众号:Web前端之巅 博客园:ht ...