一、概述
友情提示:Reactive Programming ,翻译为反应式编程,又称为响应式编程。国内多数叫响应式编程,本文我们统一使用响应式。不过,比较正确的叫法还是反应式。
Spring Framework 5 在 2017 年 9 月份,发布了 GA 通用版本。既然是一个新的大版本,必然带来了非常多的改进,其中比较重要的一点,就是将响应式编程带入了 Spring 生态。也就是说,将响应式编程“真正”带入了 Java 生态之中。
在此之前,相信(include me),对响应式编程的概念是非常模糊的。甚至说,截止到目前 2019 年 11 月份,对于国内的 Java 开发者,也是知之甚少。
对于我们来说,最早看到的就是 Spring5 提供了一个新的 Web 框架,基于响应式编程的 Spring WebFlux 。至此,SpringMVC 在“干掉” Struts 之后,难道要开始进入 Spring 自己的两个 Web 框架的双雄争霸?
实际上,WebFlux 在出来的两年时间里,据了解到的情况,鲜有项目从采用 SpringMVC 迁移到 WebFlux ,又或者新项目直接采用 WebFlux 。这又是为什么呢?
响应式编程,对我们现有的编程方式,是一场颠覆,对于框架也是。
- 在 Spring 提供的框架中,实际并没有全部实现好对响应式编程的支持。例如说,Spring Transaction 事务组件,在 Spring 5.2 M2 版本,才提供了支持响应式编程的 ReactiveTransactionManager 事务管理器。
- 更不要说,Java 生态常用的框架,例如说 MyBatis、Jedis 等等,都暂未提供响应式编程的支持。
所以,WebFlux 想要能够真正普及到我们的项目中,不仅仅需要 Spring 自己体系中的框架提供对响应式编程的很好的支持,也需要 Java 生态中的框架也要做到如此。
即使如此,这也并不妨碍我们来对 WebFlux 进行一个小小的入门。毕竟,响应式编程这把火,终将熊熊燃起。Spring Cloud Gateway即使用的的WebFlux实现。
1.1 响应式编程
简单地说,响应式编程是关于非阻塞应用程序的,这些应用程序是异步的、事件驱动的,并且需要少量的线程来垂直伸缩(即在 JVM 中),而不是水平伸缩(即通过集群)。
以后端 API 请求的处理来举例子。
在现在主流的编程模型中,请求是被同步阻塞处理完成,返回结果给前端。
在响应式的编程模型中,请求是被作为一个事件丢到线程池中执行,等到执行完毕,异步回调结果给主线程,最后返回给前端。
通过这样的方式,主线程(实际是多个,这里只是方便描述哈)不断接收请求,不负责直接同步阻塞处理,从而避免自身被阻塞。
1.2 Reactor 框架
简单来说,Reactor 说是一个响应式编程框架,又快又不占用内存的那种。
Reactor 有两个非常重要的基本概念:
-
Flux ,表示的是包含 0 到 N 个元素的异步序列。当消息通知产生时,订阅者(Subscriber)中对应的方法
#onNext(t)
,#onComplete(t)
和#onError(t)
会被调用。 - Mono 表示的是包含 0 或者 1 个元素的异步序列。该序列中同样可以包含与 Flux 相同的三种类型的消息通知。
- 同时,Flux 和 Mono 之间可以进行转换。例如:
- 对一个 Flux 序列进行计数count操作,得到的结果是一个
Mono<Long>
对象。 - 把两个 Mono 序列合并在一起,得到的是一个 Flux 对象。
- 对一个 Flux 序列进行计数count操作,得到的结果是一个
其实,可以先暂时简单把Mono 理解成 Object ,Flux 理解成 List。
1.3 Spring WebFlux
Spring 官方文档对 Spring WebFlux 介绍如下:
Spring Framework 5 提供了一个新的 spring-webflux
模块。该模块包含了:
- 对响应式支持的 HTTP 和 WebSocket 客户端。
- 对响应式支持的 Web 服务器,包括 Rest API、HTML 浏览器、WebSocket 等交互方式。
在服务端方面,WebFlux 提供了 2 种编程模型(翻译成使用方式,可能更易懂):
方式一,基于 Annotated Controller 方式实现:基于 @Controller 和 SpringMVC 使用的其它注解。也就是说,我们大体上可以像使用 SpringMVC 的方式,使用 WebFlux 。
方式二,基于函数式编程方式:函数式,Java 8 lambda 表达式风格的路由和处理。可能有点晦涩,晚点我们看了示例就会明白。
下面,开始让我们开始愉快的快速入门。
2. 快速入门
我们会使用 spring-boot-starter-webflux
实现 WebFlux 的自动化配置。然后实现用户的增删改查接口。接口列表如下:
请求方法 | URL | 功能 |
---|---|---|
GET |
/users/list |
查询用户列表 |
GET |
/users/get |
获得指定用户编号的用户 |
POST |
/users/add |
添加用户 |
POST |
/users/update |
更新指定用户编号的用户 |
POST |
/users/delete |
删除指定用户编号的用户 |
天文1号不是发射了吗!下面,开始神秘的火星之旅~
2.1 引入依赖
在IDEA中,要创建WebFlux
项目,必须勾选Spring Reactive Web
而不是传统的Spring Web
,这里为了简化代码使用到了Lombok。
在 pom.xml
文件中,引入相关依赖。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>webflux</artifactId>
<dependencies>
<!-- 实现对 Spring WebFlux 的自动化配置 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>2.3.0.RELEASE</version>
</dependency>
<!-- 方便等会写单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
2.2 Application
创建 Application.java
类,配置 @SpringBootApplication
注解即可。
package com.erbadagang.springboot.springwebflux;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
2.3 基于 Annotated Controller 方式实现
创建 [UserController] 类。代码如下:
package com.erbadagang.springboot.springwebflux.controller;
import com.erbadagang.springboot.springwebflux.dto.UserAddDTO;
import com.erbadagang.springboot.springwebflux.dto.UserUpdateDTO;
import com.erbadagang.springboot.springwebflux.service.UserService;
import com.erbadagang.springboot.springwebflux.vo.UserVO;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
/**
* 用户 Controller
*/
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
/**
* 查询用户列表
*
* @return 用户列表
*/
@GetMapping("/list")
public Flux<UserVO> list() {
// 查询列表
List<UserVO> result = new ArrayList<>();
result.add(new UserVO().setId(1).setUsername("yudaoyuanma"));
result.add(new UserVO().setId(2).setUsername("woshiyutou"));
result.add(new UserVO().setId(3).setUsername("chifanshuijiao"));
// 返回列表
return Flux.fromIterable(result);
}
/**
* 获得指定用户编号的用户
*
* @param id 用户编号
* @return 用户
*/
@GetMapping("/get")
public Mono<UserVO> get(@RequestParam("id") Integer id) {
// 查询用户
UserVO user = new UserVO().setId(id).setUsername("username:" + id);
// 返回
return Mono.just(user);
}
/**
* 获得指定用户编号的用户
*
* @param id 用户编号
* @return 用户
*/
@GetMapping("/v2/get")
public Mono<UserVO> get2(@RequestParam("id") Integer id) {
// 查询用户
UserVO user = userService.get(id);
// 返回
return Mono.just(user);
}
/**
* 添加用户
*
* @param addDTO 添加用户信息 DTO
* @return 添加成功的用户编号
*/
@PostMapping("add")
public Mono<Integer> add(@RequestBody Publisher<UserAddDTO> addDTO) {
// 插入用户记录,返回编号
Integer returnId = 1;
// 返回用户编号
return Mono.just(returnId);
}
/**
* 添加用户
*
* @param addDTO 添加用户信息 DTO
* @return 添加成功的用户编号
*/
@PostMapping("add2")
public Mono<Integer> add2(Mono<UserAddDTO> addDTO) {
// 插入用户记录,返回编号
Integer returnId = 1;
// 返回用户编号
return Mono.just(returnId);
}
/**
* 更新指定用户编号的用户
*
* @param updateDTO 更新用户信息 DTO
* @return 是否修改成功
*/
@PostMapping("/update")
public Mono<Boolean> update(@RequestBody Publisher<UserUpdateDTO> updateDTO) {
// 更新用户记录
Boolean success = true;
// 返回更新是否成功
return Mono.just(success);
}
/**
* 删除指定用户编号的用户
*
* @param id 用户编号
* @return 是否删除成功
*/
@PostMapping("/delete") // URL 修改成 /delete ,RequestMethod 改成 DELETE
public Mono<Boolean> delete(@RequestParam("id") Integer id) {
// 删除用户记录
Boolean success = true;
// 返回是否更新成功
return Mono.just(success);
}
}
- 在类和方法上,我们添加了
@Controller
和 SpringMVC 在使用的@GetMapping
和@PostMapping
等注解,提供 API 接口,这个和我们在使用 SpringMVC 是一模一样的。 - 在
dto
和vo
包下,有 API 使用到的 DTO 和 VO 类。 - 因为是入门示例,我们会发现代码十分简单,淡定,淡定(让我想起来Trump跟记者打架让闭嘴,shutup,shutup......)。在后文中,我们会提供和 Spring Data JPA、Spring Data Redis 等等整合的示例。
-
#list()
方法,我们最终调用Flux#fromIterable(Iterable<? extends T> it)
方法,将 List 包装成 Flux 对象返回。 -
#get(Integer id)
方法,我们最终调用Mono#just(T data)
方法,将 UserVO 包装成 Mono 对象返回。 -
#add(Publisher<UserAddDTO> addDTO)
方法,参数为 Publisher 类型,泛型为 UserAddDTO 类型,并且添加了@RequestBody
注解,从 request 的 Body 中读取参数。注意,此时提交参数需要使用"application/json"
等 Content-Type 内容类型。 -
#add(...)
方法,也可以使用application/x-www-form-urlencoded
或multipart/form-data
这两个 Content-Type 内容类型,通过 request 的 Form Data 或 Multipart Data 传递参数。代码如下:
// UserController.java
/**
* 添加用户
*
* @param addDTO 添加用户信息 DTO
* @return 添加成功的用户编号
*/
@PostMapping("add2")
public Mono<Integer> add(Mono<UserAddDTO> addDTO) {
// 插入用户记录,返回编号
Integer returnId = UUID.randomUUID().hashCode();
// 返回用户编号
return Mono.just(returnId);
}
此时,参数为 Mono 类型,泛型为 UserAddDTO 类型。
当然,我们也可以直接使用参数为 UserAddDTO 类型。如果后续需要使用到 Reactor API ,则我们自己主动调用 Mono#just(T data) 方法,封装出 Publisher 对象。注意,Flux 和 Mono 都实现了 Publisher 接口。
-
#update(Publisher<UserUpdateDTO> updateDTO)
方法,和#add(Publisher<UserAddDTO> addDTO)
方法一致,就不重复赘述。 -
#delete(Integer id)
方法,和#get(Integer id)
方法一致,就不重复赘述。
2.4 基于函数式编程方式
创建 [UserRouter]类。代码如下:
package com.erbadagang.springboot.springwebflux.controller;
import com.erbadagang.springboot.springwebflux.vo.UserVO;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.*;
import static org.springframework.web.reactive.function.server.ServerResponse.*;
/**
* 用户 Router
*/
@Configuration
public class UserRouter {
@Bean
public RouterFunction<ServerResponse> userListRouterFunction() {
return RouterFunctions.route(RequestPredicates.GET("/users2/list"),
new HandlerFunction<ServerResponse>() {
@Override
public Mono<ServerResponse> handle(ServerRequest request) {
// 查询列表
List<UserVO> result = new ArrayList<>();
result.add(new UserVO().setId(1).setUsername("yudaoyuanma"));
result.add(new UserVO().setId(2).setUsername("woshiyutou"));
result.add(new UserVO().setId(3).setUsername("chifanshuijiao"));
// 返回列表
return ServerResponse.ok().bodyValue(result);
}
});
}
@Bean
public RouterFunction<ServerResponse> userGetRouterFunction() {
return RouterFunctions.route(RequestPredicates.GET("/users2/get"),
new HandlerFunction<ServerResponse>() {
@Override
public Mono<ServerResponse> handle(ServerRequest request) {
// 获得编号
Integer id = request.queryParam("id")
.map(s -> StringUtils.isEmpty(s) ? null : Integer.valueOf(s)).get();
// 查询用户
UserVO user = new UserVO().setId(id).setUsername(UUID.randomUUID().toString());
// 返回列表
return ServerResponse.ok().bodyValue(user);
}
});
}
@Bean
public RouterFunction<ServerResponse> demoRouterFunction() {
return route(GET("/users2/demo"), request -> ok().bodyValue("demo"));
}
}
-
在类上,添加
@Configuration
注解,保证该类中的 Bean 们,都被扫描到。 -
在每个方法中,我们都通弄
RouterFunctions#route(RequestPredicate predicate, HandlerFunction<T> handlerFunction)
方法,定义了一条路由。- 第一个参数
predicate
参数,是 RequestPredicate 类型,请求谓语,用于匹配请求。可以通过 RequestPredicates 来构建各种条件。 - 第二个参数
handlerFunction
参数,是 RouterFunction 类型,处理器函数。
- 第一个参数
-
每个方法定义的路由,胖友自己看下代码,一眼能看的明白。一般来说,采用第三个方法的写法,更加简洁。注意,需要使用
static import
静态引入,代码如下:
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.*;
import static org.springframework.web.reactive.function.server.ServerResponse.*;
加推荐基于 Annotated Controller 方式实现的编程方式,更符合我们现在的开发习惯,学习成本也相对低一些。同时,和 API 接口文档工具 Swagger 也更容易集成。
3. 测试接口
在开发完接口,我们会进行接口的自测。一般情况下,我们先启动项目,然后使用 Postman、curl、浏览器,手工模拟请求后端 API 接口。
如访问url
实际上,WebFlux 提供了 Web 测试客户端 WebTestClient 类,方便我们快速测试接口。下面,我们对 UserController提供的接口,进行下单元测试。
MockMvc 提供了集成测试和单元测试的能力。
3.1 集成测试
创建 [UserControllerTest]测试类,我们来测试一下简单的 UserController 的每个操作。核心代码如下:
// UserControllerTest.java
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
@AutoConfigureWebFlux
@AutoConfigureWebTestClient
public class UserControllerTest {
@Autowired
private WebTestClient webClient;
@Test
public void testList() {
webClient.get().uri("/users/list")
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody().json("[\n" +
" {\n" +
" \"id\": 1,\n" +
" \"username\": \"yudaoyuanma\"\n" +
" },\n" +
" {\n" +
" \"id\": 2,\n" +
" \"username\": \"woshiyutou\"\n" +
" },\n" +
" {\n" +
" \"id\": 3,\n" +
" \"username\": \"chifanshuijiao\"\n" +
" }\n" +
"]"); // 响应结果
}
@Test
public void testGet() {
// 获得指定用户编号的用户
webClient.get().uri("/users/get?id=1")
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody().json("{\n" +
" \"id\": 1,\n" +
" \"username\": \"username:1\"\n" +
"}"); // 响应结果
}
@Test
public void testGet2() {
// 获得指定用户编号的用户
webClient.get().uri("/users/v2/get?id=1")
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody().json("{\n" +
" \"id\": 1,\n" +
" \"username\": \"test\"\n" +
"}"); // 响应结果
}
@Test
public void testAdd() {
Map<String, Object> params = new HashMap<>();
params.put("username", "yudaoyuanma");
params.put("password", "nicai");
// 添加用户
webClient.post().uri("/users/add")
.bodyValue(params)
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody().json("1"); // 响应结果。因为没有提供 content 的比较,所以只好使用 json 来比较。竟然能通过
}
@Test
public void testAdd2() { // 发送文件的测试,可以参考 https://dev.to/shavz/sending-multipart-form-data-using-spring-webtestclient-2gb7 文章
BodyInserters.FormInserter<String> formData = // Form Data 数据,需要这么拼凑
BodyInserters.fromFormData("username", "yudaoyuanma")
.with("password", "nicai");
// 添加用户
webClient.post().uri("/users/add2")
.body(formData)
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody().json("1"); // 响应结果。因为没有提供 content 的比较,所以只好使用 json 来比较。竟然能通过
}
@Test
public void testUpdate() {
Map<String, Object> params = new HashMap<>();
params.put("id", 1);
params.put("username", "yudaoyuanma");
// 修改用户
webClient.post().uri("/users/update")
.bodyValue(params)
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody(Boolean.class) // 期望返回值类型是 Boolean
.consumeWith((Consumer<EntityExchangeResult<Boolean>>) result -> // 通过消费结果,判断符合是 true 。
Assert.assertTrue("返回结果需要为 true", result.getResponseBody()));
}
@Test
public void testDelete() {
// 删除用户
webClient.post().uri("/users/delete?id=1")
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody(Boolean.class) // 期望返回值类型是 Boolean
.isEqualTo(true); // 这样更加简洁一些
// .consumeWith((Consumer<EntityExchangeResult<Boolean>>) result -> // 通过消费结果,判断符合是 true 。
// Assert.assertTrue("返回结果需要为 true", result.getResponseBody()));
}
}
- 在类上,我们添加了
@AutoConfigureWebTestClient
注解,用于自动化配置我们稍后注入的 WebTestClient Bean 对象webClient
。在后续的测试中,我们会看到都是通过webClient
调用后端 API 接口。而每一次调用后端 API 接口,都会执行真正的后端逻辑。因此,整个逻辑,走的是集成测试,会启动一个真实的 Spring 环境。 - 每次 API 接口的请求,都通过 RequestHeadersSpec 来构建。构建完成后,通过
RequestHeadersSpec#exchange()
方法来执行请求,返回 ResponseSpec 结果。- WebTestClient 的
#get()
、#head()
、#delete()
、#options()
方法,返回的是 RequestHeadersUriSpec 对象。 - WebTestClient 的
#post()
、#put()
、#delete()
、#patch()
方法,返回的是 RequestBodyUriSpec 对象。 - RequestHeadersUriSpec 和 RequestBodyUriSpec 都继承了 RequestHeadersSpec 接口。
- WebTestClient 的
- 执行完请求后,通过调用 RequestBodyUriSpec 的各种断言方法,添加对结果的预期,相当于做断言。如果不符合预期,则会抛出异常,测试不通过。
3.2 单元测试
为了更好的展示 WebFlux 单元测试的示例,我们需要改写 UserController 的代码,让其会依赖 UserService 。修改点如下:
- 创建 [UserService]类。代码如下:
// UserService.java
@Service
public class UserService {
public UserVO get(Integer id) {
return new UserVO().setId(id).setUsername("test");
}
}
- 在 [UserController]类中,增加
GET /users/v2/get
接口,获得指定用户编号的用户。代码如下:
// UserController.java
@Autowired
private UserService userService;
/**
* 获得指定用户编号的用户
*
* @param id 用户编号
* @return 用户
*/
@GetMapping("/v2/get")
public Mono<UserVO> get2(@RequestParam("id") Integer id) {
// 查询用户
UserVO user = userService.get(id);
// 返回
return Mono.just(user);
}
在代码中,我们注入了 UserService Bean 对象 userService ,然后在新增的接口方法中,会调用 UserService#get(Integer id) 方法,获得指定用户编号的用户。
创建 [UserControllerTest2]测试类,我们来测试一下简单的 UserController 的新增的这个 API 操作。代码如下:
// UserControllerTest2.java
@RunWith(SpringRunner.class)
@WebFluxTest(UserController.class)
public class UserControllerTest2 {
@Autowired
private WebTestClient webClient;
@MockBean
private UserService userService;
@Test
public void testGet2() throws Exception {
// Mock UserService 的 get 方法
System.out.println("before mock:" + userService.get(1)); // <1.1>
Mockito.when(userService.get(1)).thenReturn(
new UserVO().setId(1).setUsername("username:1")); // <1.2>
System.out.println("after mock:" + userService.get(1)); // <1.3>
// 查询用户列表
webClient.get().uri("/users/v2/get?id=1")
.exchange() // 执行请求
.expectStatus().isOk() // 响应状态码 200
.expectBody().json("{\n" +
" \"id\": 1,\n" +
" \"username\": \"username:1\"\n" +
"}"); // 响应结果
}
}
- 在类上添加
@WebFluxTest
注解,并且传入的是 UserController 类,表示我们要对 UserController 进行单元测试。 - 同时,
@WebFluxTest
注解,是包含了@UserController
的组合注解,所以它会自动化配置我们稍后注入的 WebTestClient Bean 对象mvc
。在后续的测试中,我们会看到都是通过webClient
调用后端 API 接口。但是!每一次调用后端 API 接口,并不会执行真正的后端逻辑,而是走的 Mock 逻辑。也就是说,整个逻辑,走的是单元测试,只会启动一个 Mock 的 Spring 环境。
注意上面每个加粗的地方!
-
userService
属性,我们添加了 [@MockBean
]注解,实际这里注入的是一个使用 Mockito 创建的 UserService Mock 代理对象。如下图所示:[图片上传失败...(image-46836b-1596810612894)]-
UserController 中,也会注入一个 UserService 属性,此时注入的就是该 Mock 出来的 UserService Bean 对象。
-
默认情况下,
-
<1.1>
处,我们调用UserService#get(Integer id)
方法,然后打印返回结果。执行结果如下:before mock:null
, 结果竟然返回的是null
空。理论来说,此时应该返回一个id = 1
的 UserVO 对象。实际上,因为此时的userService
是通过 Mockito 来 Mock 出来的对象,其所有调用它的方法,返回的都是空。 -
<1.2>
处,通过 Mockito 进行 MockuserService
的#get(Integer id)
方法,当传入的id = 1
方法参数时,返回id = 1
并且username = "username:1"
的 UserVO 对象。 -
<1.3>
处,再次调用UserService#get(Integer id)
方法,然后打印返回结果。执行结果如下:after cn.iocoder.springboot.lab27.springwebflux.vo.UserVO@23202c31
打印的就是我们 Mock 返回的 UserVO 对象。
-
-
后续,使用
webClient
完成一次后端 API 调用,并进行断言结果是否正确。执行成功,单元测试通过。
底线
本文源代码使用 Apache License 2.0开源许可协议,这里是本文源码Gitee地址,可通过命令git clone+地址
下载代码到本地,也可直接点击链接通过浏览器方式查看源代码。
网友评论