• RestTemplate的使用和原理你都烂熟于胸了吗?【享学Spring MVC】


    每篇一句

    人圆月圆心圆,人和家和国和---中秋节快乐

    前言

    在阅读本篇之前,建议先阅读开山篇效果更佳。RestTemplate是Spring提供的用于访问Rest服务的客户端工具,它提供了多种便捷访问远程Http服务的方法,能够大大提高客户端的编写效率
    弱弱呼吁一句:对于那些在Spring环境下还在使用HttpClient(或其它Client)的同学,今儿看完本文后,建议切换到RestTemplate (有特殊需求的当然除外喽~)。

    RestTemplate简化了与http服务的通信,程序代码可以给它提供URL,并提取结果。它默认使用的JDK 的HttpURLConnection进行通信,然而我们是可以通过RestTemplate.setRequestFactory切换到不同的HTTP源:如Apache HttpComponentsNettyOkHttp等等。

    RestOperations

    指定一组基本restful操作的接口,定义了基本的Rest操作集合,它的唯一实现是RestTemplate;不直接使用,但这是增强可测试性的一个有用选项,因为它很容易被模拟或存根(后面这句话请好好理解)。

    可以对比参照RedisOperations,它的实现类也只有RedisTemplate一个。他俩都采用了设计模式中的模板模式

    方法们:

    由于此接口里的方法实在太多了(40+个),因此我按照Http标准进行分类如下表格:

    // @since 3.0
    public enum HttpMethod {
    	GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE;
    	...
    }
    
    HttpMethod 方法
    GET 在这里插入图片描述
    HEAD 在这里插入图片描述
    POST 在这里插入图片描述
    PUT 在这里插入图片描述
    PATCH 在这里插入图片描述
    DELETE 在这里插入图片描述
    OPTIONS 在这里插入图片描述
    TRACE
    any(执行任何Http方法) 在这里插入图片描述

    观察发现,虽然方法众多但有很强的规律可循。每个方法都有三种重载实现:2种的url参数为字符串,一种URI参数,所以掌握规律后再使用,就不用害怕它的多而不知咋使用了。

    xxxForObject:返回响应体(也就直接是body体力的内容) (T)
    xxxForEntity:返回的相应行、响应头、响应码、响应体等等 (ResponseEntity
    xxxForLocation:提交成功之后,返回新资源的URI。这个只需要服务提供者返回一个 URI 即可,该 URI 表示新资源的位置,可谓非常轻量。 (URI)

    注意:使用字符串类型的url默认会对url进行转义,如http://example.com/hotel list在执行时会转义为http://example.com/hotel%20list,隐式的转义这样是没有问题的。但如果你自己已经转义过了,那就不ok了。
    若不想要这种隐式的转义,建议使用URI(URI uri = uriComponents.toUri())来构造。

    RestTemplate中POST请求的三种方式

    post请求代表新建/创建一个资源,所以它是有返回值的。因为它的使用最为复杂,因此本文以它为例进行讲解。

    你如果熟练使用过浏览器的开发者工具调试过,你肯定知道POST请求它传参是有两种方式的:

    1. Form Data方式:我们用from表单提交的方式就是它;使用ajax(注意:这里指的是jQuery的ajax,而不是源生js的)默认的提交方式也是它~
      在这里插入图片描述
    2. request payload方式:多部分方式/json方式
      在这里插入图片描述
      在这里插入图片描述

    这两种方式是通过Content-Type来区别的:若是application/x-www-form-urlencoded那就是formdata方式;若是application/json或者multipart/form-data等方式那就是request payload方式

    jQuery在执行post请求时,默认会给你设置Content-Typeapplication/x-www-form-urlencoded,所以服务器能够正确解析。
    若使用js原生的ajax,如果不显示的设置Content-Type,那么默认是text/plain,这时服务器就不知道怎么解析数据了,所以才只能通过获取原始数据流的方式来进行解析请求数据。(相信没人这么干吧~)

    exchange和execute方法:

    exchange方法:更通用的请求方法。它入参必须接受一个RequestEntity,从而可以设置请求的路径、头等等信息,最终全都是返回一个ResponseEntity(可以发送Get、Post、Put等所有请求)。
    execute方法:最最最底层、通用的请求方法。

    RequestCallback:用于操作请求头和body,在请求发出执行;ResponseExtractor:解析/提取HTTP响应的数据,而且不需要担心异常和资源的关闭
    RequestCallback.doWithRequest(ClientHttpRequest)说白了就是拿到ClientHttpRequest后对他进行继续处理~
    RestTemplateacceptHeaderRequestCallback、httpEntityCallback这些方法可以设置它~


    HttpAccessor、InterceptingHttpAccessor

    这两个抽象类不容忽视,HystrixCommand和Ribbon的逻辑都和它有关系(拦截器)。
    HttpAccessor是个抽象基类,它定义要操作ClientHttpRequestFactory的公共属性,它一般不直接使用。

    // @since 3.0
    public abstract class HttpAccessor {
    	
    	// RestTemplate默认使用的客户端工厂:基于源生JDK
    	private ClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
    
    	// 若要切换成三方库的底层组件,设置此方法便可
    	public void setRequestFactory(ClientHttpRequestFactory requestFactory) {
    		this.requestFactory = requestFactory;
    	}
    	... // get方法
    	
    	// 供给子类非常方便的拿到一个ClientHttpRequest
    	protected ClientHttpRequest createRequest(URI url, HttpMethod method) throws IOException {
    		ClientHttpRequest request = getRequestFactory().createRequest(url, method);
    		return request;
    	}	
    }
    

    它的子类是:InterceptingHttpAccessor,也还是个抽象实现,主要是管理起了请求的拦截器们:ClientHttpRequestInterceptor

    InterceptingHttpAccessor

    // @since 3.0
    // @see InterceptingClientHttpRequestFactory
    public abstract class InterceptingHttpAccessor extends HttpAccessor {
    
    	// 装载需要作用在RestTemplate上的拦截器们~~~
    	private final List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>();
    	@Nullable
    	private volatile ClientHttpRequestFactory interceptingRequestFactory;
    
    	// 这里语意是set,所以是完全的替换掉(支持ordered排序哦~~~)
    	public void setInterceptors(List<ClientHttpRequestInterceptor> interceptors) {
    		if (this.interceptors != interceptors) {
    			this.interceptors.clear();
    			this.interceptors.addAll(interceptors);
    			AnnotationAwareOrderComparator.sort(this.interceptors);
    		}
    	}
    
    	// 复写了父类的这个方法很有意思
    	// 意思为:若你调用者手动set进来了,那就以调用者设置的工厂为准 否则使用的是InterceptingClientHttpRequestFactory
    	@Override
    	public void setRequestFactory(ClientHttpRequestFactory requestFactory) {
    		super.setRequestFactory(requestFactory);
    		this.interceptingRequestFactory = null;
    	}
    
    	// 若配置了拦截器,那么默认就使用InterceptingClientHttpRequestFactory,而不再是SimpleClientHttpRequestFactory了~~~
    	@Override
    	public ClientHttpRequestFactory getRequestFactory() {
    		List<ClientHttpRequestInterceptor> interceptors = getInterceptors();
    		if (!CollectionUtils.isEmpty(interceptors)) {
    			ClientHttpRequestFactory factory = this.interceptingRequestFactory;
    			if (factory == null) {
    				factory = new InterceptingClientHttpRequestFactory(super.getRequestFactory(), interceptors);
    				this.interceptingRequestFactory = factory;
    			}
    			return factory;
    		} else {
    			return super.getRequestFactory();
    		}
    	}
    }
    

    InterceptingHttpAccessor最主要的处理逻辑为:若发现调用者设置了请求拦截器,那么它创建的工厂是具有拦截功能的InterceptingClientHttpRequestFactory,否则就是默认的SimpleClientHttpRequestFactory

    InterceptingClientHttpRequestFactory工厂它产生的ClientHttpRequestInterceptingClientHttpRequest,然而它就会执行拦截器的拦截方法喽:nextInterceptor.intercept(request, body, this)

    提问:如有配置有多个请求拦截器,都会执行吗?
    解答:这个千万不要犯迷糊和轻易下结论:以为没有迭代它(for循环)而只是iterator.next()就以为若有多个就只会执行一个,那就大错特错了。这里实际是形成了一个执行链条,只要拦截器的intercept方法内最终还调用执行器的intercept()方法,那么拦截器链就会一直执行下去。其根本缘由是第三个参数传入的是this,至始至终都是同一个执行器(this=InterceptingRequestExecution




    RestTemplate

    RestTemplate采用同步方式执行 HTTP 请求的类,底层默认使用JDK原生 HttpURLConnection API 。它实现了接口RestOperations,提供了非常多的模版方法(重载方法)让开发者能更简单地发送 HTTP 请求。

    需要注意的是,RestTemplateSpring 3.0就有了,但在Spring5.0后,Spring官方是推荐使用org.springframework.web.reactive.function.client.WebClient替代它,特别是对于异步的场景。

    RestTemplate因为使用极其广泛,so即使到了Spring 5.0,官方只是建议替代,但并没有标注@Deprecated,因此至少目前你还可以想咋用就咋用吧。
    但是AsyncRestTemplate是明确标注了@Deprecated,强烈建议使用org.springframework.web.reactive.function.client.WebClient去代替,所以在5.0后不建议再使用它了~。

    当然还需要说明一点:若你的项目中没有使用到WebFlux的技术栈来处理请求,那么也没必要说为了使用而使用,所以没必要专门为了它而导包(个人建议)~

    // @since 3.0
    public class RestTemplate extends InterceptingHttpAccessor implements RestOperations {
    	// 去classpath探测  是否有这些消息转换器相关的jar~
    	// 一般情况下我们都会导jackson2Present~~~
    	private static boolean romePresent;
    	private static final boolean jaxb2Present;
    	private static final boolean jackson2Present;
    	private static final boolean jackson2XmlPresent;
    	private static final boolean jackson2SmilePresent;
    	private static final boolean jackson2CborPresent;
    	private static final boolean gsonPresent;
    	private static final boolean jsonbPresent;
    	...
    	
    	// 下面四个变量很重要:
    
    	// 消息转换器们(显然对JSON格式默认是支持得最好的)
    	private final List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
    	// 默认的请求异常处理器,Spring5.0后其实可以使用它ExtractingResponseErrorHandler
    	// 它能够利用消息换换气提取你的错误内容。并且还支持自定义错误码、错误序列等等~
    	private ResponseErrorHandler errorHandler = new DefaultResponseErrorHandler();
    	// 用于URL的构建
    	private UriTemplateHandler uriTemplateHandler;
    	// 默认的返回值提取器~~~~
    	private final ResponseExtractor<HttpHeaders> headersExtractor = new HeadersExtractor();
    
    	// 空构造,应该是平时使用得最多的了:一切都使用默认的组件配置Resource等等
    	public RestTemplate() {
    		// 这个几个消息转换器是支持的。字节数组、字符串、
    		this.messageConverters.add(new ByteArrayHttpMessageConverter());
    		this.messageConverters.add(new StringHttpMessageConverter());
    		this.messageConverters.add(new ResourceHttpMessageConverter(false));
    		this.messageConverters.add(new SourceHttpMessageConverter<>());
    		// 对form表单提交方式的支持
    		this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
    
    		// 接下里便是一些列的判断,若类路径上有才会加进来
    		if (jackson2Present) {
    			this.messageConverters.add(new MappingJackson2HttpMessageConverter());
    		}
    		...
    		// new DefaultUriBuilderFactory()
    		this.uriTemplateHandler = initUriTemplateHandler();
    	}
    
    	// 你懂的,若想用OkHttp,也可以在构造时就指定
    	public RestTemplate(ClientHttpRequestFactory requestFactory) {
    		this();
    		setRequestFactory(requestFactory);
    	}
    
    	// 若不想用默认的消息转换器,也可以自己指定(其实一般都不这么去干,而是后面自己再add进来)
    	public RestTemplate(List<HttpMessageConverter<?>> messageConverters) {
    		Assert.notEmpty(messageConverters, "At least one HttpMessageConverter required");
    		this.messageConverters.addAll(messageConverters);
    		this.uriTemplateHandler = initUriTemplateHandler();
    	}
    	... // 省略上面属性的get/set犯法们
    }
    

    这部分源码我列出来,都是在对构建一个RestTemplate实例的准备工作相关方法,包括对各个相关组件的设置。

    接下来更重要的便是它实现的接口方法了,我抽出一些关键点进行描述说明:

    RestTemplate:
    
    	@Override
    	@Nullable
    	public <T> T getForObject(String url, Class<T> responseType, Object... uriVariables) throws RestClientException {
    		//1、new AcceptHeaderRequestCallback(responseType)  它能在发送请求的之前这样一件事:
    		// request.getHeaders().setAccept(allSupportedMediaTypes)
    		RequestCallback requestCallback = acceptHeaderRequestCallback(responseType);
    		HttpMessageConverterExtractor<T> responseExtractor = new HttpMessageConverterExtractor<>(responseType, getMessageConverters(), logger);
    
    		// 最终调用的是execute方法,此时URL是个字符串
    		// responseExtractor返回值提取器使用的是消息转换器去读取body哒~
    		// 返回值就是返回的body本身(不含有返回的响应头等等信息~)
    		return execute(url, HttpMethod.GET, requestCallback, responseExtractor, uriVariables);
    	}
    
    	// 它返回的是ResponseEntity,不会返回null的  最终调用的依旧是execute方法
    	// 此时候用的就不是消息转换器的提取器了,而是内部类`ResponseEntityResponseExtractor`(底层还是依赖消息转换器)
    	// 但是这个提取器,提取出来的可都是ResponseEntity<T>实例~
    	@Override
    	public <T> ResponseEntity<T> getForEntity(String url, Class<T> responseType, Object... uriVariables) throws RestClientException {
    		RequestCallback requestCallback = acceptHeaderRequestCallback(responseType);
    		ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(responseType);
    		return nonNull(execute(url, HttpMethod.GET, requestCallback, responseExtractor, uriVariables));
    	}
    
    	// HEAD请求:很简单,使用的提取器就是headersExtractor,从返回值里把响应header拿出来即可
    	@Override
    	public HttpHeaders headForHeaders(String url, Object... uriVariables) throws RestClientException {
    		return nonNull(execute(url, HttpMethod.HEAD, null, headersExtractor(), uriVariables));
    	}
    
    
    	// POST请求
    	@Override
    	@Nullable
    	public URI postForLocation(String url, @Nullable Object request, Object... uriVariables) throws RestClientException {
    		// 1、HttpEntityRequestCallback  适配:把request适配成一个HttpEntity
    		// 然后执行前,通过消息转换器把头信息、body信息等等都write进去
    		RequestCallback requestCallback = httpEntityCallback(request);
    		// 因为需要拿到URI,所以此处使用headersExtractor提取器先拿到响应的header即可~~~
    		HttpHeaders headers = execute(url, HttpMethod.POST, requestCallback, headersExtractor(), uriVariables);
    		return (headers != null ? headers.getLocation() : null);
    	}
    
    	// 除了httpEntityCallback()不一样,其余和get请求一样
    	@Override
    	@Nullable
    	public <T> T postForObject(String url, @Nullable Object request, Class<T> responseType, Object... uriVariables) throws RestClientException {
    		RequestCallback requestCallback = httpEntityCallback(request, responseType);
    		HttpMessageConverterExtractor<T> responseExtractor =
    				new HttpMessageConverterExtractor<>(responseType, getMessageConverters(), logger);
    		return execute(url, HttpMethod.POST, requestCallback, responseExtractor, uriVariables);
    	}
    
    	// PUT请求:因为没有返回值,所以不需要返回值提取器。所以,非常的简单~~~
    	@Override
    	public void put(String url, @Nullable Object request, Object... uriVariables) throws RestClientException {
    		RequestCallback requestCallback = httpEntityCallback(request);
    		execute(url, HttpMethod.PUT, requestCallback, null, uriVariables);
    	}
    
    	// DELETE请求:也是木有返回值的。
    	// 并且请注意:DELETE请求这里可都是不能接收body的,不能给请求设置请求体的
    	// (虽然可能底层httpCLient支持,但这里不支持,请遵守规范)
    	@Override
    	public void delete(String url, Object... uriVariables) throws RestClientException {
    		execute(url, HttpMethod.DELETE, null, null, uriVariables);
    	}
    
    	// OPTIONS请求:和HEAD请求的处理逻辑几乎一样
    	@Override
    	public Set<HttpMethod> optionsForAllow(String url, Object... uriVariables) throws RestClientException {
    		ResponseExtractor<HttpHeaders> headersExtractor = headersExtractor();
    		HttpHeaders headers = execute(url, HttpMethod.OPTIONS, null, headersExtractor, uriVariables);
    		return (headers != null ? headers.getAllow() : Collections.emptySet());
    	}
    

    所有方法大体执行逻辑一致,都是和RequestCallback responseExtractor等有关,且最终都是委托给了最为底层的execute()方法去执行。

    你是否疑问:它提供的put方法返回值都是void,若我put请求就有返回值肿么办呢?那么接下来就介绍更为通用的一个方法:exchange()

    RestTemplate:
    
    	@Override
    	public <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity, Class<T> responseType, Map<String, ?> uriVariables) throws RestClientException {
    		// 把请求体适配为HttpEntity
    		RequestCallback requestCallback = httpEntityCallback(requestEntity, responseType);
    		// 消息提取器使用ResponseEntityResponseExtractor
    		ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(responseType);
    
    		// 从上两个部分就能看到:exchange方法的入参、出参都是非常通用的~~~
    		return nonNull(execute(url, method, requestCallback, responseExtractor, uriVariables));
    	}
    
    	// ParameterizedTypeReference参数化类型,用于处理泛型
    	// 上面的responseType就是个Class。这里是个参数化类型~~~~~
    	@Override
    	public <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity, ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException {
    
    		Type type = responseType.getType();
    		RequestCallback requestCallback = httpEntityCallback(requestEntity, type);
    		ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(type);
    		return nonNull(execute(url, method, requestCallback, responseExtractor, uriVariables));
    	}
    
    	// 这个方法就非常精简了,让调用者自己去构造RequestEntity,里面是包含了请求的URL和方法等信息的
    	@Override
    	public <T> ResponseEntity<T> exchange(RequestEntity<?> requestEntity, Class<T> responseType) throws RestClientException {
    		RequestCallback requestCallback = httpEntityCallback(requestEntity, responseType);
    		ResponseExtractor<ResponseEntity<T>> responseExtractor = responseEntityExtractor(responseType);
    		return nonNull(doExecute(requestEntity.getUrl(), requestEntity.getMethod(), requestCallback, responseExtractor));
    	}
    

    exchange所有方法使用的都是HttpEntityResponseEntity代表请求实体和响应实体,足以见到它设计的通用性。

    在Spring3.2后提供了ParameterizedTypeReference来处理参数化类型---> 主要是为了处理List等的泛型

    可以发现即使是exchange()方法,最终还是委托给execute/doExecute去执行的:

    RestTemplate:
    
    	// 3个execute方法。最终调用的都是doExecute方法
    	// 它做的一件事:使用UriTemplateHandler把URL的参数填进去~~~
    	// 底层使用的是我上文介绍的`UriComponentsBuilder`,还是比较简单的
    	@Override
    	@Nullable
    	public <T> T execute(String url, HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor<T> responseExtractor, Object... uriVariables) throws RestClientException {
    		URI expanded = getUriTemplateHandler().expand(url, uriVariables);
    		return doExecute(expanded, method, requestCallback, responseExtractor);
    	}
    
    doExecute方法:
    	@Nullable
    	protected <T> T doExecute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
    		ClientHttpResponse response = null;
    		ClientHttpRequest request = createRequest(url, method);
    		// 如果有回调,那就先回调处理一下子请求
    		if (requestCallback != null) {
    			requestCallback.doWithRequest(request);
    		}
    		// 真正意义上的发送请求。
    		// 请注意:如果这里的request是`InterceptingClientHttpRequest`,那就回执行拦截器的intercept方法哦~~~
    		// 至于什么时候是InterceptingClientHttpRequest呢?这个上面有讲的
    		response = request.execute();
    		// 处理结果(若有错误,那就抛出异常~~~)
    		handleResponse(url, method, response);
    		
    		// 请求正常。那就使用返回值提取器responseExtractor提取出内容即可了~~~
    		return (responseExtractor != null ? responseExtractor.extractData(response) : null);
    		...
    		// 关闭响应(ClientHttpResponse继承了Closeable接口)
    		finally {
    			if (response != null) {
    				response.close();
    			}
    		}
    	}
    

    看完doExecute()的模板式的实现步骤,就清楚了RestTemplate从发出一个请求到收到一个响应的完整过程。Spring设计了多个相关组件,提供钩子程序让我们可以干预到流程里面去,最常见的当然就是请求拦截器了,它在Ribbon负载均衡和Hystrix熔断器里面有很好的应用~

    AsyncRestTemplate

    它是@since 4.0新增的用于解决一些异步Http请求的场景,但它寿命比较短,在Spring5.0就标记为@Deprecated,而被推荐使用WebClient去代替它。

    它的实现基础原理是:RestTemplate + SimpleAsyncTaskExecutor任务池的方式去实现的异步请求,返回值均为ListenableFuture。掌握了RestTemplate后,它使用起来是没有什么障碍的

    极简使用Demo Show

    看过了原理的描述,我有理由相信你已经烂熟于胸并对RestTemplate能够运用自如了。因此关于使用方面,本文只给如下非常简单的一个Demo Show我认为是够了的:

    public static void main(String[] args) throws IOException {
        RestTemplate restTemplate = new RestTemplate();
        String pageHtml = restTemplate.getForObject("http://www.baidu.com", String.class);
        System.out.println(pageHtml); // 百度首页的html...
    }
    

    解释一点:这里请求得到的是一个html网页,所以HttpMessageConverterExtractor去提取响应时,使用的是StringHttpMessageConverter去处理的,提取代码如下:

    StringHttpMessageConverter:
    	@Override
    	protected String readInternal(Class<? extends String> clazz, HttpInputMessage inputMessage) throws IOException {
    		// 从响应头的contentType里提取(若是application/json,那默认也是urf-8)
    		// 若没有指定编码,就取值getDefaultCharset。比如本处访问百度,就取值默认值`ISO-8859-1`对body体进行编码的~
    		Charset charset = getContentTypeCharset(inputMessage.getHeaders().getContentType());
    		return StreamUtils.copyToString(inputMessage.getBody(), charset);
    	}
    

    小伙伴把此请求案例可以和上面我使用ClientHttpRequestFactory发送请求的案例对比(或者和你自己使用HttpClient步骤对比),感受感受使用RestTemplate是多么的优雅~

    推荐阅读

    RestTemplate组件:ClientHttpRequestFactory、ClientHttpRequestInterceptor、ResponseExtractor【享学Spring MVC】
    为何一个@LoadBalanced注解就能让RestTemplate拥有负载均衡的能力?【享学Spring Cloud】

    总结

    微服务作为主流的今天,RestTemplate可谓是一把利器,每个程序员都应该掌握它。深入理解它对实际应用、调优都有很现实的意义,所以我相信本文能够帮助到你,做到烂熟于胸。
    预告一下:下篇文章会原理分析告诉大家为何一个简单的@LoadBalanced注解就能让RestTemplate拥有负载均衡的能力?

    == 若对Spring、SpringBoot、MyBatis等源码分析感兴趣,可加我wx:fsx641385712,手动邀请你入群一起飞 ==
    == 若对Spring、SpringBoot、MyBatis等源码分析感兴趣,可加我wx:fsx641385712,手动邀请你入群一起飞 ==

  • 相关阅读:
    0719PHP基础:PDO
    0717PHP基础:面向对象
    0716PHP基础:面向对象
    0715JS基础:ajax
    0715PHP练习:文件操作
    0715PHP基础:文件操作
    0629正则表达式:基础
    0628正则表达式:练习
    zTree简单使用和代码结构
    servlet
  • 原文地址:https://www.cnblogs.com/yourbatman/p/11532755.html
Copyright © 2020-2023  润新知