Spring Security 初始化流程详解

栏目: Java · 发布时间: 6年前

内容简介:最近在整合微服务OAuth 2认证过程中,它是基于Spring Security之上,而本人对Spring Security架构原理并不太熟悉,导致很多配置搞不太清楚,遂咬牙啃完了Spring Security核心源码,花了差不多一星期,总体上来说,其代码确实比较晦涩,之前在学习Apache Shiro框架之前也曾经在相关论坛里了解过,相比Spring Security,Apache Shiro真的是相当轻量,代码研读起来容易很多,而Spring Security类继承结构复杂,大量使用了其所谓Builde

最近在整合微服务OAuth 2认证过程中,它是基于Spring Security之上,而本人对Spring Security架构原理并不太熟悉,导致很多配置搞不太清楚,遂咬牙啃完了Spring Security核心源码,花了差不多一星期,总体上来说,其代码确实比较晦涩,之前在学习Apache Shiro框架之前也曾经在相关论坛里了解过,相比Spring Security,Apache Shiro真的是相当轻量,代码研读起来容易很多,而Spring Security类继承结构复杂,大量使用了其所谓Builder和Configuer模式,其代码跟踪过程很痛苦,遂记录下,分享给有需要的人,由于本人能力有限,在文章中有不对之处,还请各位执教,在此谢谢各位了。

本人研读的Spring Security版本为: 5.1.4.RELEASE

Spring Security在3.2版本之后支持Java Configuration,即:通过 Java 编码形式配置Spring Security,可不再依赖XML文件配置,本文采用Java Configuration方式。

在Spring Security官方文档中有一个最简配置例子:

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.authentication.builders.*;
import org.springframework.security.config.annotation.web.configuration.*;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .inMemoryAuthentication()
                .withUser("user").password("password").roles("USER");
    }
}

我们先不要看其它内容,先关注注解 @EnableWebSecurity ,它是初始化Spring Security的入口,打开其源码如下:

@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = { java.lang.annotation.ElementType.TYPE })
@Documented
@Import({ WebSecurityConfiguration.class,
        SpringWebMvcImportSelector.class,
        OAuth2ImportSelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {

    /**
     * Controls debugging support for Spring Security. Default is false.
     * @return if true, enables debug support with Spring Security
     */
    boolean debug() default false;
}

该注解类通过 @Configuration@Import 配合使用引入了一个配置类( WebSecurityConfiguration )和两个ImportSelector( SpringWebMvcImportSelectorOAuth2ImportSelector ),我们重点关注下 WebSecurityConfiguration ,它是Spring Security的核心,正是它构建初始化了所有的Bean实例和相关配置,下面我们详细分析下。

打开 WebSecurityConfiguration 源码,发现它被 @Configuration 标记,说明它是配置类,

@Configuration
public class WebSecurityConfiguration implements ImportAware, BeanClassLoaderAware

该类中最重要的工作就是实例并注册 FilterChainProxy ,也就是我们在以前XML文件中配置的过滤器:

<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>

该过滤器负责拦截请求,并把请求通过一定的匹配规则(通过RequestMatcher匹配实现)路由(或者Delegate)到具体的 SecurityFilterChain ,源码如下:

/**
     * Creates the Spring Security Filter Chain
     * @return the {@link Filter} that represents the security filter chain
     * @throws Exception
     */
    @Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = webSecurityConfigurers != null
                && !webSecurityConfigurers.isEmpty();
        if (!hasConfigurers) {
            WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            webSecurity.apply(adapter);
        }
        return webSecurity.build();
    }

@Bean 注解 name 属性值 AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME 就是XML中定义的 springSecurityFilterChain

从源码中知道过滤器通过最后的 webSecurity.build() 创建, webSecurity 的类型为: WebSecurity ,它在 setFilterChainProxySecurityConfigurer 方法中优先被创建了:

/**
     * Sets the {@code <SecurityConfigurer<FilterChainProxy, WebSecurityBuilder>}
     * instances used to create the web configuration.
     *
     * @param objectPostProcessor the {@link ObjectPostProcessor} used to create a
     * {@link WebSecurity} instance
     * @param webSecurityConfigurers the
     * {@code <SecurityConfigurer<FilterChainProxy, WebSecurityBuilder>} instances used to
     * create the web configuration
     * @throws Exception
     */
    @Autowired(required = false)
    public void setFilterChainProxySecurityConfigurer(
            ObjectPostProcessor<Object> objectPostProcessor,
            @Value("#{@autowiredWebSecurityConfigurersIgnoreParents.getWebSecurityConfigurers()}") List<SecurityConfigurer<Filter, WebSecurity>> webSecurityConfigurers)
            throws Exception {
        webSecurity = objectPostProcessor
                .postProcess(new WebSecurity(objectPostProcessor));
        if (debugEnabled != null) {
            webSecurity.debug(debugEnabled);
        }

        Collections.sort(webSecurityConfigurers, AnnotationAwareOrderComparator.INSTANCE);

        Integer previousOrder = null;
        Object previousConfig = null;
        for (SecurityConfigurer<Filter, WebSecurity> config : webSecurityConfigurers) {
            Integer order = AnnotationAwareOrderComparator.lookupOrder(config);
            if (previousOrder != null && previousOrder.equals(order)) {
                throw new IllegalStateException(
                        "@Order on WebSecurityConfigurers must be unique. Order of "
                                + order + " was already used on " + previousConfig + ", so it cannot be used on "
                                + config + " too.");
            }
            previousOrder = order;
            previousConfig = config;
        }
        for (SecurityConfigurer<Filter, WebSecurity> webSecurityConfigurer : webSecurityConfigurers) {
            webSecurity.apply(webSecurityConfigurer);
        }
        this.webSecurityConfigurers = webSecurityConfigurers;
    }

从代码中可以看到,它是直接被new出来的:

webSecurity = objectPostProcessor
                .postProcess(new WebSecurity(objectPostProcessor));

setFilterChainProxySecurityConfigurer方法参数中需要被注入两个对象: objectPostProcessorwebSecurityConfigurersobjectPostProcessor 是在 ObjectPostProcessorConfiguration 配置类中注册的,而 webSecurityConfigurers 则是使用了 @Value 注解方式,注解内容为: #{@autowiredWebSecurityConfigurersIgnoreParents.getWebSecurityConfigurers()} ,通过源码了解, autowiredWebSecurityConfigurersIgnoreParents 是在本类中被注册:

@Bean
    public static AutowiredWebSecurityConfigurersIgnoreParents autowiredWebSecurityConfigurersIgnoreParents(
            ConfigurableListableBeanFactory beanFactory) {
        return new AutowiredWebSecurityConfigurersIgnoreParents(beanFactory);
    }

在AutowiredWebSecurityConfigurersIgnoreParents中定义了方法: getWebSecurityConfigurers

@SuppressWarnings({ "rawtypes", "unchecked" })
    public List<SecurityConfigurer<Filter, WebSecurity>> getWebSecurityConfigurers() {
        List<SecurityConfigurer<Filter, WebSecurity>> webSecurityConfigurers = new ArrayList<SecurityConfigurer<Filter, WebSecurity>>();
        Map<String, WebSecurityConfigurer> beansOfType = beanFactory
                .getBeansOfType(WebSecurityConfigurer.class);
        for (Entry<String, WebSecurityConfigurer> entry : beansOfType.entrySet()) {
            webSecurityConfigurers.add(entry.getValue());
        }
        return webSecurityConfigurers;
    }

它通过BeanFactory获取了类型为 WebSecurityConfigurer 的Bean实例列表。回到 WebSecurityConfiguration 类中的 setFilterChainProxySecurityConfigurer 方法,它把 WebSecurityConfigurer 列表设置到了 WebSecurity 中,源码如下:

for (SecurityConfigurer<Filter, WebSecurity> webSecurityConfigurer : webSecurityConfigurers) {
            webSecurity.apply(webSecurityConfigurer);
        }

通过 apply 方法,apply方法其实就是 webSecurityConfigurer 放入 webSecurity 维护的 configurers 属性中, configurers 是个 LinkedHashMap ,源码如下:

/**
     * Applies a {@link SecurityConfigurer} to this {@link SecurityBuilder} overriding any
     * {@link SecurityConfigurer} of the exact same class. Note that object hierarchies
     * are not considered.
     *
     * @param configurer
     * @return the {@link SecurityConfigurerAdapter} for further customizations
     * @throws Exception
     */
    public <C extends SecurityConfigurer<O, B>> C apply(C configurer) throws Exception {
        add(configurer);
        return configurer;
    }

其中代码 add(configurer) 就是将这些 webSecurityConfigurer 添加到 webSecurityconfigurers 属性中。

现在 webSecurity 的初始化工作已经完成,现在回到 springSecurityFilterChain 方法中,它首先检查当前是否配置了 webSecurityConfigurer ,如果没有的会默认设置一个,并且调用上面提到的 apply 方法,源码如下:

boolean hasConfigurers = webSecurityConfigurers != null
                && !webSecurityConfigurers.isEmpty();
        if (!hasConfigurers) {
            WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            webSecurity.apply(adapter);
        }

如果已经存在配置了 webSecurityConfigurer ,则调用 webSecurity.build() 进行构建。

在进入 build 方法之前,首先简单介绍下WebSecurity的继承结构,

Spring Security 初始化流程详解

它实现了 SecurityBuilder 接口,继承自 AbstractConfiguredSecurityBuilderAbstractConfiguredSecurityBuilder 继承自 AbstractSecurityBuilderAbstractSecurityBuilder 实现了 SecurityBuilder ,其中 AbstractConfiguredSecurityBuilder 实现了通过自定义 SecurityConfigurer 类来配置 SecurityBuilder ,上面提到的 apply(SecurityConfigurer configurer) 就是在该类中实现的,它把configurer保存在它维护的 LinkedHashMap<Class<? extends SecurityConfigurer<O, B>>, List<SecurityConfigurer<O, B>>> configurers = new LinkedHashMap<Class<? extends SecurityConfigurer<O, B>>, List<SecurityConfigurer<O, B>>>() 中。

调用 webSecurity.build() 后,首先调用的父类 AbstractSecurityBuilder 中的 build 方法:

public final O build() throws Exception {
        if (this.building.compareAndSet(false, true)) {
            this.object = doBuild();
            return this.object;
        }
        throw new AlreadyBuiltException("This object has already been built");
    }

然后调用 doBuild()doBuild() 在子类中实现, AbstractConfiguredSecurityBuilder 实现了该方法:

@Override
    protected final O doBuild() throws Exception {
        synchronized (configurers) {
            buildState = BuildState.INITIALIZING;

            beforeInit();
            init();

            buildState = BuildState.CONFIGURING;

            beforeConfigure();
            configure();

            buildState = BuildState.BUILDING;

            O result = performBuild();

            buildState = BuildState.BUILT;

            return result;
        }
    }

在这里重点关注 init()configure()performBuild() ,下面逐个分析它们的作用。

init() 方法在 AbstractConfiguredSecurityBuilder 实现:

private void init() throws Exception {
        Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();

        for (SecurityConfigurer<O, B> configurer : configurers) {
            configurer.init((B) this);
        }

        for (SecurityConfigurer<O, B> configurer : configurersAddedInInitializing) {
            configurer.init((B) this);
        }
    }

它的工作是迭代调用所有配置的 SecurityConfigrerinit 方法,在这里其实是它的子类 WebSecurityConfigurer ,因为之前获取时指定的类型就是 WebSecurityConfigurer ,在上文中提到 AutowiredWebSecurityConfigurersIgnoreParents.getWebSecurityConfigurers() 中:

Map<String, WebSecurityConfigurer> beansOfType = beanFactory.getBeansOfType(WebSecurityConfigurer.class);

而实现了 WebSecurityConfigurer 接口的就是 WebSecurityConfigurerAdapterWebSecurityConfigurerAdapter.init() 源码如下:

public void init(final WebSecurity web) throws Exception {
        final HttpSecurity http = getHttp();
        web.addSecurityFilterChainBuilder(http).postBuildAction(new Runnable() {
            public void run() {
                FilterSecurityInterceptor securityInterceptor = http
                        .getSharedObject(FilterSecurityInterceptor.class);
                web.securityInterceptor(securityInterceptor);
            }
        });
    }

它只要完成两件重要的事情:

  1. 初始化 HttpSecurity 对象;
  2. 设置 HttpSecurity 对象添加至 WebSecuritysecurityFilterChainBuilders 列表中;

初始化 HttpSecurity 对象在 getHttp() 方法中实现:

protected final HttpSecurity getHttp() throws Exception {
        if (http != null) {
            return http;
        }

        DefaultAuthenticationEventPublisher eventPublisher = objectPostProcessor
                .postProcess(new DefaultAuthenticationEventPublisher());
        localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher);

        AuthenticationManager authenticationManager = authenticationManager();
        authenticationBuilder.parentAuthenticationManager(authenticationManager);
        authenticationBuilder.authenticationEventPublisher(eventPublisher);
        Map<Class<? extends Object>, Object> sharedObjects = createSharedObjects();

        http = new HttpSecurity(objectPostProcessor, authenticationBuilder,
                sharedObjects);
        if (!disableDefaults) {
            // @formatter:off
            http
                .csrf().and()
                .addFilter(new WebAsyncManagerIntegrationFilter())
                .exceptionHandling().and()
                .headers().and()
                .sessionManagement().and()
                .securityContext().and()
                .requestCache().and()
                .anonymous().and()
                .servletApi().and()
                .apply(new DefaultLoginPageConfigurer<>()).and()
                .logout();
            // @formatter:on
            ClassLoader classLoader = this.context.getClassLoader();
            List<AbstractHttpConfigurer> defaultHttpConfigurers =
                    SpringFactoriesLoader.loadFactories(AbstractHttpConfigurer.class, classLoader);

            for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) {
                http.apply(configurer);
            }
        }
        configure(http);
        return http;
    }

从代码中可以了解, HttpSecurity 是直接被new出来的,在创建 HttpSecurity 之前,首先初始化了 AuthenticationManagerBuilder 对象,这里有段代码很重要就是: AuthenticationManager authenticationManager = authenticationManager(); ,它创建 AuthenticationManager 实例,打开 authenticationManager() 方法:

protected AuthenticationManager authenticationManager() throws Exception {
        if (!authenticationManagerInitialized) {
            configure(localConfigureAuthenticationBldr);
            if (disableLocalConfigureAuthenticationBldr) {
                authenticationManager = authenticationConfiguration
                        .getAuthenticationManager();
            }
            else {
                authenticationManager = localConfigureAuthenticationBldr.build();
            }
            authenticationManagerInitialized = true;
        }
        return authenticationManager;
    }

在初始化时,它会调用 configure(localConfigureAuthenticationBldr); ,默认的实现是:

protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        this.disableLocalConfigureAuthenticationBldr = true;
    }

【1、个性化配置入口之 configure(AuthenticationManagerBuilder auth)

我们可以通过继承 WebSecurityConfigurerAdapter 并重写该方法来个性化配置 AuthenticationManager

构建完 authenticationManager 实例后,将它设置为 authenticationBuilder 的父认证管理器:

authenticationBuilder.parentAuthenticationManager(authenticationManager);

并将该 authenticationBuilder 传入 HttpSecurity 构造器构建 HttpSecurity 实例。

构建完 HttpSecurity 实例后,默认情况下会添加默认的拦截其配置:

http
                .csrf().and()
                .addFilter(new WebAsyncManagerIntegrationFilter())
                .exceptionHandling().and()
                .headers().and()
                .sessionManagement().and()
                .securityContext().and()
                .requestCache().and()
                .anonymous().and()
                .servletApi().and()
                .apply(new DefaultLoginPageConfigurer<>()).and()
                .logout();

最后调用 configure(http); ,这又是一个可个性化的配置入口,它的默认实现是:

protected void configure(HttpSecurity http) throws Exception {
        logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");

        http
            .authorizeRequests()
                .anyRequest().authenticated()
                .and()
            .formLogin().and()
            .httpBasic();
    }

默认的配置是拦截所有的请求需要认证之后才能访问,如果没有认证,会自动生成一个认证表单要求输入用户名和密码。

【2、个性化配置入口之 configure(HttpSecurity http)

我们可以通过继承 WebSecurityConfigurerAdapter 并重写该方法来个性化配置 HttpSecurity

OK,目前为止 HttpSecurity 已经被初始化,接下去需要设置 HttpSecurity 对象添加至 WebSecuritysecurityFilterChainBuilders 列表中:

web.addSecurityFilterChainBuilder(http).postBuildAction(new Runnable() {
            public void run() {
                FilterSecurityInterceptor securityInterceptor = http
                        .getSharedObject(FilterSecurityInterceptor.class);
                web.securityInterceptor(securityInterceptor);
            }
        });

打开 HttpSecurity 类结构,和WebSecurity一样,它也实现了 SecurityBuilder 接口,同样继承自 AbstractConfiguredSecurityBuilder

Spring Security 初始化流程详解

当所有的 WebSecurityConfigurerinit 方法被调用之后, webSecurity.init() 工作就结束了。

接下去调用了 webSecurity.configure() ,该方法同样是在 AbstractConfiguredSecurityBuilder 中实现的:

private void configure() throws Exception {
        Collection<SecurityConfigurer<O, B>> configurers = getConfigurers();

        for (SecurityConfigurer<O, B> configurer : configurers) {
            configurer.configure((B) this);
        }
    }

它的主要工作是迭代调用所有 WebSecurityConfigurerconfigurer 方法,参数是 WebSeucrity 本身,这又是另外一个重要的个性化入口:

【3、个性化配置入口之 configure(WebSecurity web)

我们可以通过继承 WebSecurityConfigurerAdapter 并重写该方法来个性化配置 WebSecurity

自此,三个重要的个性化入口都已经被调用,即在实现 WebSecurityConfigurerAdapter 经常需要重写的:

1、configure(AuthenticationManagerBuilder auth);

2、configure(WebSecurity web);

3、configure(HttpSecurity http);

回到webSecurity构建过程,接下去重要的的调用:

O result = performBuild();

该方法在 WebSecurityConfigurerAdapter 中实现,返回的就是过滤器FilterChainProxy,源码如下:

@Override
    protected Filter performBuild() throws Exception {
        Assert.state(
                !securityFilterChainBuilders.isEmpty(),
                () -> "At least one SecurityBuilder<? extends SecurityFilterChain> needs to be specified. "
                        + "Typically this done by adding a @Configuration that extends WebSecurityConfigurerAdapter. "
                        + "More advanced users can invoke "
                        + WebSecurity.class.getSimpleName()
                        + ".addSecurityFilterChainBuilder directly");
        int chainSize = ignoredRequests.size() + securityFilterChainBuilders.size();
        List<SecurityFilterChain> securityFilterChains = new ArrayList<>(
                chainSize);
        for (RequestMatcher ignoredRequest : ignoredRequests) {
            securityFilterChains.add(new DefaultSecurityFilterChain(ignoredRequest));
        }
        for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : securityFilterChainBuilders) {
            securityFilterChains.add(securityFilterChainBuilder.build());
        }
        FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
        if (httpFirewall != null) {
            filterChainProxy.setFirewall(httpFirewall);
        }
        filterChainProxy.afterPropertiesSet();

        Filter result = filterChainProxy;
        if (debugEnabled) {
            logger.warn("\n\n"
                    + "********************************************************************\n"
                    + "**********        Security debugging is enabled.       *************\n"
                    + "**********    This may include sensitive information.  *************\n"
                    + "**********      Do not use in a production system!     *************\n"
                    + "********************************************************************\n\n");
            result = new DebugFilter(filterChainProxy);
        }
        postBuildAction.run();
        return result;
    }

首先计算出 chainSize ,也就是 ignoredRequests.size() + securityFilterChainBuilders.size(); ,如果你不配置 ignoredRequests ,那就是 securityFilterChainBuilders.size() ,也就是 HttpSecurity 的个数,其本质上就是你一共配置几个 WebSecurityConfigurerAdapter ,因为每个 WebSecurityConfigurerAdapter 对应一个 HttpSecurity ,而所谓的 ignoredRequests 就是 FilterChainProxy 的请求,默认是没有的,如果你需要条跳过某些请求不需要认证或授权,可以如下配置:

@Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/statics/**");
    }

在上面配置中,所有以 /statics 开头请求都将被 FilterChainProxy 忽略。

计算完 chainSize 后,就会创建 List<SecurityFilterChain> securityFilterChains = new ArrayList<>(chainSize); ,遍历所有的 HttpSecurity ,调用 HtppSecuritybuild() 构建其对应的过滤器链 SecurityFilterChain 实例,并将 SecurityFilterChain 添加到 securityFilterChains 列表中:

for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : securityFilterChainBuilders) {
            securityFilterChains.add(securityFilterChainBuilder.build());
        }

调用 HtppSecuritybuild() 构建其实和调用 WebSecuritybuild() 构建类类似,父类中方法一次被执行,最后执行本身的 performBuild() 方法,其源码如下:

@Override
    protected DefaultSecurityFilterChain performBuild() throws Exception {
        Collections.sort(filters, comparator);
        return new DefaultSecurityFilterChain(requestMatcher, filters);
    }

构建 SecurityFilterChain 主要是完成 RequestMatcher 和对应的过滤器列表,我们都知道在Spring Security中,过滤器执行按顺序顺序的,这个 排序 就是在 performBuild() 中完成的,也就是:

Collections.sort(filters, comparator);

它通过一个比较器实现了过滤器的排序,这个比较器就是 FilterComparator ,有兴趣的朋友可以自己去了解详情。

最后返回的是 SecurityFilterChain 的默认实现 DefaultSecurityFilterChain

构建完所有 SecurityFilterChain 后,创建最为重要的 FilterChainProxy 实例,

FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);

构造器中传入 SecurityFilterChain 列表,如果开启了Debug模式,还会被包装成 DebugFilter 类型,共开发调试使用,默认是关闭的,可以通过过下面方式开启Debug模式:

@Override
    public void configure(WebSecurity web) throws Exception {
        web.debug(true);
    }

至此Spring Security 初始化完成,我们通过继承 WebSecurityConfigurerAdapter 来代达到个性化配置目的,文中提到了三个重要的个性化入口,并且 WebSecurityConfigurerAdapter 是可以配置多个的,其对应的接口就是会存在多个 SecurityFilterChain 实例,但是它们人仍然在同一个FilterChainProxy中,通过 RequestMatcher 来匹配并传入到对应的 SecurityFilterChain 中执行请求。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Usability for the Web

Usability for the Web

Tom Brinck、Darren Gergle、Scott D. Wood / Morgan Kaufmann / 2001-10-15 / USD 65.95

Every stage in the design of a new web site is an opportunity to meet or miss deadlines and budgetary goals. Every stage is an opportunity to boost or undercut the site's usability. Thi......一起来看看 《Usability for the Web》 这本书的介绍吧!

MD5 加密
MD5 加密

MD5 加密工具

SHA 加密
SHA 加密

SHA 加密工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试