Spring Security 初始化流程详解

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

内容简介:最近在整合微服务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 中执行请求。


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

查看所有标签

猜你喜欢:

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

用户体验草图设计

用户体验草图设计

比尔·巴克斯顿(Bill Buxton) / 黄峰 / 电子工业出版社 / 2009-11 / 168.00元

《用户体验草图设计:正确地设计,设计得正确(全彩)》:比尔·盖茨亲笔推荐版 人因国际、百度、华为、微软、腾讯用户体验部门联合推荐!一起来看看 《用户体验草图设计》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

SHA 加密
SHA 加密

SHA 加密工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具