API网关与zuul1.x与springcloud的三角关系

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

内容简介:API 网关可以看做系统与外界联通的入口,我们可以在网关处理一些非业务逻辑的逻辑,比如权限验证,监控,缓存,请求路由等等。因此API网关可以承接两个方向的入口。API网关一般按照职责链的模式实现,核心链路一般分为三个部分: 预处理、请求转发和处理结果。职责链可以通过过滤器的方式去实现,过滤器中定义是否需要执行和执行的顺序,通过上下文变量透传给每个过滤器。

API 网关可以看做系统与外界联通的入口,我们可以在网关处理一些非业务逻辑的逻辑,比如权限验证,监控,缓存,请求路由等等。因此API网关可以承接两个方向的入口。

  1. 移动APP/WEB的统一入口网关。
  2. 业务方快速提开放能力。

2. 如何实现一个网关

2.1 网关核心

API网关一般按照职责链的模式实现,核心链路一般分为三个部分: 预处理、请求转发和处理结果。

职责链可以通过过滤器的方式去实现,过滤器中定义是否需要执行和执行的顺序,通过上下文变量透传给每个过滤器。

2.1.1 预处理

这一环节,可以可插拔式的,扩展很多过滤器,例如:

2.1.1.1 初始化API

将API信息、服务提供方信息查出来,并验证API的合法性。

2.1.1.2 API鉴权

对API进行鉴权认证,可自定义鉴权方式,例如OAuth2、签名认证。

2.1.1.3 访问控制

对API的访问进行控制,调用者是否进入黑名单,调用方是否已授权调用该API。

2.1.1.4 限流控制

对API进行流量控制,可以根据调用者、API两个维度进行流量控制,流量控制相对比较灵活,可以按照组合方式进行流控。

2.1.1.5 参数转换

根据API路由到后端地址的规则,进行参数转换,构建出需要请求的参数。

2.1.2 请求转发

这一环节,可以根据协议的不通选择不同的转发方式,rpc、http协议转发的方式不同,这一环节可以借助一些框架来实现,rpc可选择dubbo、http可选择ribbon,这样方便解决负载均衡的调用。同时可以为调用做资源隔离、保证路由转发时具备容错机制,市面上较为主流的为hystrix。

2.1.3 处理结果

这一环节,对于调用需要处理的报文进行处理,记录下来,用于对调用情况做分析统计。同时也对一些异常情况处理,加上默认的响应报文。

API网关与zuul1.x与springcloud的三角关系

2.2 网关设计图

API网关与zuul1.x与springcloud的三角关系

3. netlifx-zuul 1.x

zuul是由netflix开源的一个网关,可以提供动态的路由、监控和安全性保证。 zuul 1.x是基于servlet构建的一个框架,通过一系列filter,完成职责链的设计模式。而zuul1.x主要包含了四类过滤器:

  1. pre: 请求路由被调用前的前置过滤器。
  2. route:真正实现路由转发的过滤器,这种过滤器讲请求转发至真实的后端微服务,返回相关请求结果。
  3. post: 收到请求后进行调用。用于收集统计信息,处理响应报文。
  4. error: 在任意阶段发生错误后会执行,进行统一的异常处理。

3.1 Zuul Request Lifecycle

API网关与zuul1.x与springcloud的三角关系

3.2 zuul实现

API网关与zuul1.x与springcloud的三角关系

3.2.1 ZuulServlet

ZuulServlet是Zuul的转发引擎,所有的请求都由该servlet统一处理,调用servlet的service函数对请求进行过滤。

public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException {
        try {
            // 初始化当前的zuul request context,将request和response放入上下文中
            init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);

            // Marks this request as having passed through the "Zuul engine", as opposed to servlets
            // explicitly bound in web.xml, for which requests will not have the same data attached
            RequestContext context = RequestContext.getCurrentContext();
            context.setZuulEngineRan();
            //////////////// zuul对请求的处理流程 start  ////////////////
            // zuul对一个请求的处理流程:pre -> route -> post
            // 1. post是必然执行的(可以类比finally块),但如果在post中抛出了异常,交由error处理完后就结束,避免无限循环
            // 2. 任何阶段抛出了ZuulException,都会交由error处理
            // 3. 非ZuulException会被封装后交给error处理
            try {
                preRoute();
            } catch (ZuulException e) {
                error(e);
                postRoute();
                return;
            }
            try {
                route();
            } catch (ZuulException e) {
                error(e);
                postRoute();
                return;
            }
            try {
                postRoute();
            } catch (ZuulException e) {
                error(e);
                return;
            }

        } catch (Throwable e) {
            error(new ZuulException(e, 500, "UNHANDLED_EXCEPTION_" + e.getClass().getName()));
        } finally {
            // 此次请求完成,移除相应的上下文对象
            RequestContext.getCurrentContext().unset();
        }
    }
复制代码

3.2.2 RequestContext

保存请求、响应、状态信息和数据,以便zuulfilters访问和共享,可以通过设置ContextClass来替换RequestContext的扩展。

3.2.3 ZuulRunner

该类将servlet请求和响应初始化为RequestContext并包装FilterProcessor(filter的处理器)调用,用于处理 reRoute(), route(), postRoute(), and error()。

3.2.4 FilterProcessor

过滤器的处理器,核心函数是runFilters():

/**
     * runs all filters of the filterType sType/ Use this method within filters to run custom filters by type
     *
     * @param sType the filterType.
     * @return
     * @throws Throwable throws up an arbitrary exception
     */
    public Object runFilters(String sType) throws Throwable {
        if (RequestContext.getCurrentContext().debugRouting()) {
            Debug.addRoutingDebug("Invoking {" + sType + "} type filters");
        }
        boolean bResult = false;
        // 通过FilterLoader获取指定类型的所有filter
        List<ZuulFilter> list = FilterLoader.getInstance().getFiltersByType(sType);
        if (list != null) {
            // 这里没有进行try...catch... 意味着只要任何一个filter执行失败了整个过程就会中断掉
            for (int i = 0; i < list.size(); i++) {
                ZuulFilter zuulFilter = list.get(i);
                Object result = processZuulFilter(zuulFilter);
                if (result != null && result instanceof Boolean) {
                    bResult |= ((Boolean) result);
                }
            }
        }
        return bResult;
    }
复制代码

3.2.5 ZuulFilter

/**
     * runFilter checks !isFilterDisabled() and shouldFilter(). The run() method is invoked if both are true.
     *
     * @return the return from ZuulFilterResult
     */
    public ZuulFilterResult runFilter() {
        ZuulFilterResult zr = new ZuulFilterResult();
        // 当前filter是否被禁用
        if (!isFilterDisabled()) {
            if (shouldFilter()) {
                Tracer t = TracerFactory.instance().startMicroTracer("ZUUL::" + this.getClass().getSimpleName());
                try {
                    Object res = run();
                    //包装结果
                    zr = new ZuulFilterResult(res, ExecutionStatus.SUCCESS);
                } catch (Throwable e) {
                    t.setName("ZUUL::" + this.getClass().getSimpleName() + " failed");
                    zr = new ZuulFilterResult(ExecutionStatus.FAILED);
                    zr.setException(e);
                } finally {
                    t.stopAndLog();
                }
            } else {
                zr = new ZuulFilterResult(ExecutionStatus.SKIPPED);
            }
        }
        return zr;
    }
复制代码

3.2.6 FilterRegistry

Filter 注册类,包含一个ConcurrentHashMap, 按照类型保存filter。

3.2.7 FilterLoader

用来通过加载groovy的过滤器文件,注册到FilterRegistry。

/**
     * From a file this will read the ZuulFilter source code, compile it, and add it to the list of current filters
     * a true response means that it was successful.
     * 从一个文件中,read出filter的源代码,编译它,并将其添加到当前过滤器列表中。
     *
     * @param file
     * @return true if the filter in file successfully read, compiled, verified and added to Zuul
     * @throws IllegalAccessException
     * @throws InstantiationException
     * @throws IOException
     */
    public boolean putFilter(File file) throws Exception {
        String sName = file.getAbsolutePath() + file.getName();
        if (filterClassLastModified.get(sName) != null && (file.lastModified() != filterClassLastModified.get(sName))) {
            LOG.debug("reloading filter " + sName);
            filterRegistry.remove(sName);
        }
        ZuulFilter filter = filterRegistry.get(sName);
        if (filter == null) {
            Class clazz = COMPILER.compile(file);
            if (!Modifier.isAbstract(clazz.getModifiers())) {
                filter = (ZuulFilter) FILTER_FACTORY.newInstance(clazz);
                List<ZuulFilter> list = hashFiltersByType.get(filter.filterType());
                if (list != null) {
                    hashFiltersByType.remove(filter.filterType()); //rebuild this list
                }
                filterRegistry.put(file.getAbsolutePath() + file.getName(), filter);
                filterClassLastModified.put(sName, file.lastModified());
                return true;
            }
        }

        return false;
    }
复制代码

8.FileManager

/**
     * Initialized the GroovyFileManager.
     *
     * @param pollingIntervalSeconds the polling interval in Seconds 多少秒进行轮训
     * @param directories            Any number of paths to directories to be polled may be specified
     * @throws IOException
     * @throws IllegalAccessException
     * @throws InstantiationException
     */
    public static void init(int pollingIntervalSeconds, String... directories) throws Exception, IllegalAccessException, InstantiationException {
        if (INSTANCE == null) INSTANCE = new FilterFileManager();
        //文件夹路径 ["src/main/groovy/filters/pre", "src/main/groovy/filters/route", "src/main/groovy/filters/post"]
        INSTANCE.aDirectories = directories;
        //轮训时间
        INSTANCE.pollingIntervalSeconds = pollingIntervalSeconds;
        //按照文件夹路径扫出以.groovy文件结尾的文件数组,然后通过FilterLoader读取filter,并放入filter到内存中。
        INSTANCE.manageFiles();
        //一直轮训的线程
        INSTANCE.startPoller();

    }
复制代码

3.2.8 StartServer

StartServer是一个ServletContextListener,负责在web应用启动后执行一些初始化操作

4 spring-cloud-netflix-zuul

API网关与zuul1.x与springcloud的三角关系

4.1 spring-cloud做了什么?

4.1.1.ZuulHandlerMapping

ZuulHandlerMapping在注册发生在第一次请求发生的时候,在ZuulHandlerMapping.lookupHandler方法中执行。在ZuulHandlerMapping.registerHandlers方法中首先获取所有的路由,然后调用AbstractUrlHandlerMapping.registerHandler将路由中的路径和ZuulHandlerMapping相关联。

@Override
	protected Object lookupHandler(String urlPath, HttpServletRequest request) throws Exception {
		if (this.errorController != null && urlPath.equals(this.errorController.getErrorPath())) {
			return null;
		}
		if (isIgnoredPath(urlPath, this.routeLocator.getIgnoredPaths())) return null;
		RequestContext ctx = RequestContext.getCurrentContext();
		if (ctx.containsKey("forward.to")) {
			return null;
		}
		//默认dirty为true,第一次请求进入。
		if (this.dirty) {
			synchronized (this) {
				if (this.dirty) {
				    //注册handler,将自定义的路由映射到springmvc的map中。
					registerHandlers();
					this.dirty = false;
				}
			}
		}
		//调用抽象类的lookupHandler,匹配不到的话,直接抛出404。ZuulHandlerMapping借助springmvc特性,做路由匹配。
		return super.lookupHandler(urlPath, request);
	}

	private boolean isIgnoredPath(String urlPath, Collection<String> ignored) {
		if (ignored != null) {
			for (String ignoredPath : ignored) {
				if (this.pathMatcher.match(ignoredPath, urlPath)) {
					return true;
				}
			}
		}
		return false;
	}

	private void registerHandlers() {
	    //通过路由定位器扫出路由信息,遍历路由,调用springmvc的路由。转发的handler是自定义的ZuulController,用于包装ZuulServlet。
		Collection<Route> routes = this.routeLocator.getRoutes();
		if (routes.isEmpty()) {
			this.logger.warn("No routes found from RouteLocator");
		}
		else {
			for (Route route : routes) {
				registerHandler(route.getFullPath(), this.zuul);
			}
		}
	}
复制代码

4.1.2 ZuulController

ZuulController是ZuulServlet的一个包装类,ServletWrappingController是将当前应用中的某个Servlet直接包装为一个Controller,所有到ServletWrappingController的请求实际上是由它内部所包装的这个Servlet来处理。

public class ZuulController extends ServletWrappingController {

	public ZuulController() {
		setServletClass(ZuulServlet.class);
		setServletName("zuul");
		setSupportedMethods((String[]) null); // Allow all
	}

	@Override
	public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
		try {
			// We don't care about the other features of the base class, just want to
			// handle the request
			return super.handleRequestInternal(request, response);
		}
		finally {
			// @see com.netflix.zuul.context.ContextLifecycleFilter.doFilter
			RequestContext.getCurrentContext().unset();
		}
	}

}
复制代码

4.1.3 RouteLocator

RouteLocator有三个实现类:SimpleRouteLocator、DiscoveryClientRouteLocator、CompositeRouteLocator。CompositeRouteLocator是一个综合的路由定位器,会包含当前定义的所有路由定位器。

4.1.4 springcloud提供的filter

pre filter 位置 是否执行 作用
ServletDetectionFilter -3 一直执行 判断该请求是否过dispatcherServlet,是否从spring mvc转发过来
Servlet30WrapperFilter -2 一直执行 包装HttpServletRequest
FormBodyWrapperFilter -1 Content-Type为application/x-www-form-urlencoded或multipart/form-data request包装成FormBodyRequestWrapper
DebugFilter 1 配置了zuul.debug.parameter或者请求中包含zuul.debug.parameter 设置debugRouting和debugRequest参数设置为true,可以通过开启此参数,激活debug信息。
PreDecorationFilter 5 上下文不存在forward.to和serviceId两个参数 从上下文解析出地址,然后取出路由信息,将路由信息放入上下文中。

4.1.4.1 ServletDetectionFilter

判断该请求是否过dispatcherServlet,是否从spring mvc转发过来。

@Override
	public Object run() {
		RequestContext ctx = RequestContext.getCurrentContext();
		HttpServletRequest request = ctx.getRequest();
		if (!(request instanceof HttpServletRequestWrapper) 
				&& isDispatcherServletRequest(request)) {
			ctx.set(IS_DISPATCHER_SERVLET_REQUEST_KEY, true);
		} else {
			ctx.set(IS_DISPATCHER_SERVLET_REQUEST_KEY, false);
		}

		return null;
	}
复制代码
@Bean
	@ConditionalOnMissingBean(name = "zuulServlet")
	public ServletRegistrationBean zuulServlet() {
	//servlet
		ServletRegistrationBean<ZuulServlet> servlet = new ServletRegistrationBean<>(new ZuulServlet(),
				this.zuulProperties.getServletPattern());
		// The whole point of exposing this servlet is to provide a route that doesn't
		// buffer requests.
		servlet.addInitParameter("buffer-requests", "false");
		return servlet;
	}
复制代码

4.1.4.2 Servlet30WrapperFilter

关于Servlet30WrapperFilter的存在,存在意义不是很大,主要是为了给zuul1.2.2版本容错,最新版的zuul1.x已经修改,bug原因是,从zuul获取的request包装类,拿到的是HttpServletRequestWrapper,老版本的zuul,是这么做的:

public class HttpServletRequestWrapper implements HttpServletRequest 
复制代码

而在tomcat容器中的ApplicationDispatcher类中对request包装类判断,会导致直接break。

while (!same) {
            if (originalRequest.equals(dispatchedRequest)) {
                same = true;
            }
            if (!same && dispatchedRequest instanceof ServletRequestWrapper) {
                dispatchedRequest =
                    ((ServletRequestWrapper) dispatchedRequest).getRequest();
            } else {
                break;
            }
        }
复制代码

参考: github.com/spring-clou…

route filter 位置 是否执行 作用
RibbonRoutingFilter 10 一直执行 判断该请求是否过dispatcherServlet,是否从spring mvc转发过来
SimpleHostRoutingFilter 100 上下文包含routeHost 包装HttpServletRequest
SendForwardFilter 500 上下文中包含forward.to 获取转发的地址,做跳转。

4.1.4.3 RibbonRoutingFilter

// 根据上下文创建command,command是hystrix包裹后的实例。
protected ClientHttpResponse forward(RibbonCommandContext context) throws Exception {
    RibbonCommand command = this.ribbonCommandFactory.create(context);
    try {
        ClientHttpResponse response = command.execute();
        return response;
    }catch (HystrixRuntimeException ex) {
        return handleException(info, ex);
    }
}

复制代码

RibbonCommand根据RibbonCommandFactory来创建,工厂类一共有三个实现类,分别对应三种http调用框架:httpClient、okHttp、restClient。默认选择HttpClient:

@Configuration
	@ConditionalOnRibbonHttpClient
	protected static class HttpClientRibbonConfiguration {

		@Autowired(required = false)
		private Set<FallbackProvider> zuulFallbackProviders = Collections.emptySet();

		@Bean
		@ConditionalOnMissingBean
		public RibbonCommandFactory<?> ribbonCommandFactory(
				SpringClientFactory clientFactory, ZuulProperties zuulProperties) {
			return new HttpClientRibbonCommandFactory(clientFactory, zuulProperties, zuulFallbackProviders);
		}
	}

复制代码

4.1.4.4 RibbonCommand

以默认HttpClientRibbonCommand为例:

public HttpClientRibbonCommand create(final RibbonCommandContext context) {
    //获取所有ZuulFallbackProvider,即当Zuul调用失败后的降级方法
    FallbackProvider zuulFallbackProvider = getFallbackProvider(context.getServiceId());
    //创建转发的client类,是RibbonLoadBalancingHttpClient类型的。
    final String serviceId = context.getServiceId();
    final RibbonLoadBalancingHttpClient client = this.clientFactory.getClient(serviceId, RibbonLoadBalancingHttpClient.class);
    //设置LoadBalancer
    client.setLoadBalancer(this.clientFactory.getLoadBalancer(serviceId));
    // 创建Command,设置hystrix配置的众多参数。
    return new HttpClientRibbonCommand(serviceId, client, context, zuulProperties, zuulFallbackProvider, clientFactory.getClientConfig(serviceId));
}
复制代码

RibbonCommand根据模板的设计模式,抽象类中有默认的实现方式:

@Override
	protected ClientHttpResponse run() throws Exception {
		final RequestContext context = RequestContext.getCurrentContext();

		RQ request = createRequest();
		RS response;
		
		boolean retryableClient = this.client instanceof AbstractLoadBalancingClient
				&& ((AbstractLoadBalancingClient)this.client).isClientRetryable((ContextAwareRequest)request);
		
		if (retryableClient) {
			response = this.client.execute(request, config);
		} else {
			response = this.client.executeWithLoadBalancer(request, config);
		}
		context.set("ribbonResponse", response);

		// Explicitly close the HttpResponse if the Hystrix command timed out to
		// release the underlying HTTP connection held by the response.
		//
		if (this.isResponseTimedOut()) {
			if (response != null) {
				response.close();
			}
		}

		return new RibbonHttpResponse(response);
	}
复制代码

4.1.4.5 executeWithLoadBalancer

当调用者希望将请求分派给负载均衡器选择的服务器时,应该使用此方法,而不是在请求的URI中指定服务器。

/**
     * This method should be used when the caller wants to dispatch the request to a server chosen by
     * the load balancer, instead of specifying the server in the request's URI. 
     * It calculates the final URI by calling {@link #reconstructURIWithServer(com.netflix.loadbalancer.Server, java.net.URI)}
     * and then calls {@link #executeWithLoadBalancer(ClientRequest, com.netflix.client.config.IClientConfig)}.
     * 
     * @param request request to be dispatched to a server chosen by the load balancer. The URI can be a partial
     * URI which does not contain the host name or the protocol.
     */
    public T executeWithLoadBalancer(final S request, final IClientConfig requestConfig) throws ClientException {
    // 专门用于失败切换其他服务端进行重试的 Command
        LoadBalancerCommand<T> command = buildLoadBalancerCommand(request, requestConfig);

        try {
            return command.submit(
                new ServerOperation<T>() {
                    @Override
                    public Observable<T> call(Server server) {
                        URI finalUri = reconstructURIWithServer(server, request.getUri());
                        S requestForServer = (S) request.replaceUri(finalUri);
                        try {
                            return Observable.just(AbstractLoadBalancerAwareClient.this.execute(requestForServer, requestConfig));
                        } 
                        catch (Exception e) {
                            return Observable.error(e);
                        }
                    }
                })
                .toBlocking()
                .single();
        } catch (Exception e) {
            Throwable t = e.getCause();
            if (t instanceof ClientException) {
                throw (ClientException) t;
            } else {
                throw new ClientException(e);
            }
        }
        
    }
复制代码
public Observable<T> submit(final ServerOperation<T> operation) {
    // ...
    
    // 外层的 observable 为了不同目标的重试
    // selectServer() 是进行负载均衡,返回的是一个 observable,可以重试,重试时再重新挑选一个目标server
    Observable<T> o = selectServer().concatMap(server -> {
    	// 这里又开启一个 observable 主要是为了同机重试
    	Observable<T> o = Observable
	      .just(server)
	      .concatMap(server -> {
	          return operation.call(server).doOnEach(new Observer<T>() {
	          	 @Override
                 public void onCompleted() {
                 	// server 状态的统计,譬如消除联系异常,抵消activeRequest等
                 }
                 
                 @Override
                 public void onError() {
                 	// server 状态的统计,错误统计等
                 }
                 
                 @Override
                 public void onNext() {
                    // 获取 entity, 返回内容
                 }
	          });
	    })
	    // 如果设置了同机重试,进行重试
	    if (maxRetrysSame > 0) 
	        // retryPolicy 判断是否重试,具体分析看下面
	        o = o.retry(retryPolicy(maxRetrysSame, true));
	    return o;
    })
    
    // 设置了异机重试,进行重试
    if (maxRetrysNext > 0) 
        o = o.retry(retryPolicy(maxRetrysNext, false));
    
    return o.onErrorResumeNext(exp -> {
    	return Observable.error(e);
    });
}
复制代码
API网关与zuul1.x与springcloud的三角关系

关于默认情形下为什么不会重试?参考: blog.didispace.com/spring-clou…

4.1.4.6 ribbon的IRule负载均衡策略

默认选择ZoneAvoidanceRule策略,该策略剔除不可用区域,判断出最差的区域,在剩下的区域中,将按照服务器实例数的概率抽样法选择,从而判断判定一个zone的运行性能是否可用,剔除不可用的zone(的所有server),AvailabilityPredicate用于过滤掉连接数过多的Server。

具体的策略参考该博文: ju.outofmemory.cn/entry/25384…

post filter 位置 是否执行 作用
LocationRewriteFilter 900 http响应码是3xx 对 状态是 301 ,相应头中有 Location 的相应进行处理
SendResponseFilter 1000 没有抛出异常,RequestContext中的throwable属性为null(如果不为null说明已经被error过滤器处理过了,这里的post过滤器就不需要处理了),并且RequestContext中zuulResponseHeaders、responseDataStream、responseBody三者有一样不为null(说明实际请求的响应不为空)。 将服务的响应数据写入当前响应
error filter 位置 是否执行 作用
SendErrorFilter 0 上下文throable不为null 处理上下文有错误的filter

4.2 借助spring-cloud如何扩展?

  1. 如果服务发现不是用eureka,要自己重写服务发现逻辑,也就是ribbon获取ServerList,前提是用ribbon。
  2. 如果用ribbon,并且用RibbonCommand,那么会捆绑Hystrix组件,容错不可自选。
  3. 如果走rpc协议,需要自己重写route的所有逻辑。
  4. springcloud的autoconfig,默认开启了很多配置,需要禁用filter、以及重写一些bean的创建。

4.3 总结

spring cloud对于zuul的封装比较完善,同时也表现出较难扩展,尤其对ribbon、hystrix等组件不够熟悉的前提下,使用它无非是给自己未来制造难题,相比之下原生的zuul-core相对比较简单和灵活,但是开发成本较高。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

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

A Philosophy of Software Design

A Philosophy of Software Design

John Ousterhout / Yaknyam Press / 2018-4-6 / GBP 14.21

This book addresses the topic of software design: how to decompose complex software systems into modules (such as classes and methods) that can be implemented relatively independently. The book first ......一起来看看 《A Philosophy of Software Design》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具