SpringMVC 解析(三) Controller 注解
我在前面的文章中介绍了Spring MVC最核心的组件DispatcherServlet,DispatcherServlet把Servlet容器(如Tomcat)中的请求和Spring中的组件联系到一起,是SpringWeb应用的枢纽。但是我们在日常开发中往往不需要详细知道枢纽的作用,我们只需要处理枢纽分发给我们的请求。Spring中处理请求业务逻辑最常见的组件是Controller,本文会对Spring的Controller及相关组件做详细介绍。
Controller的定义
Controller是Spring中的一个特殊组件,这个组件会被Spring识别为可以接受并处理网页请求的组件。Spring中提供了基于注解的Controller定义方式:@Controller和@RestController注解。基于注解的Controller定义不需要继承或者实现接口,用户可以自由的定义接口签名。以下为Spring Controller定义的示例。
@Controller
public class HelloController {
@GetMapping("/hello")
public String handle(Model model) {
model.addAttribute("message", "Hello World!");
return "index";
}
}
@Controller注解继承了Spring的@Component注解,会把对应的类声明为Spring对应的Bean,并且可以被Web组件管理。@RestController注解是@Controller和@ResponseBody的组合,@ResponseBody表示函数的返回不需要渲染为View,应该直接作为Response的内容写回客户端。
映射关系RequestMapping
路径的定义
定义好一个Controller之后,我们需要将不同路径的请求映射到不同的Controller方法之上,Spring同样提供了基于注解的映射方式:@RequestMapping。通常情况下,用户可以在Controller类和方法上面添加@RequestMapping注解,Spring容器会识别注解并将满足路径条件的请求分配到对应的方法进行处理。在下面的示例中,”GET /persons/xxx”会调用getPerson
方法处理。
@RestController
@RequestMapping("/persons")
class PersonController {
@GetMapping("/{id}")
public Person getPerson(@PathVariable Long id) {
// ...
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public void add(@RequestBody Person person) {
// ...
}
}
路径的匹配
Spring支持两种路径匹配方式,二者之间可以很好的兼容,Spring默认使用PathPattern进行路径的匹配。
- PathPattern:使用预解析的方法匹配路径。专门为Web路径匹配而设计,可以支持复杂的表达式,执行效率很高。
- AntPathMatcher:Spring中用于类路径、文件系统和其它资源的解决方案,效率比较低。
PathPattern基本可以向下兼容AntPathMatcher的逻辑,并且支持路径变量和”**”多段路径匹配,以下列出几种PathPattern的示例:
路径示例 | 说明 |
---|---|
/resources/ima?e.png | 路径中有一个字符是可变的,如/resources/image.png |
/resources/*.png | 路径中多个字符是可变的,如/resources/test.png |
/resources/** | 路径中多段可变,如/resources/test/path/xxx |
/projects/{project}/versions | 匹配一段路径,并且把路径中的值提取出来,如/projects/MyApp/versions |
/projects/{project:[a-z]+}/versions | 匹配一段符合正则表达式路径,并且把路径中的值提取出来,如/projects/myapp/versions |
路径中匹配到的变量可以使用@PathVariable获取,Path变量可以是方法或者类级别的,匹配到的变量会自动进行类型转换,如果转换失败则会抛出异常。
@GetMapping("/owners/{ownerId}/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
// ...
}
@Controller
@RequestMapping("/owners/{ownerId}")
public class OwnerController {
@GetMapping("/pets/{petId}")
public Pet findPet(@PathVariable Long ownerId, @PathVariable Long petId) {
// ...
}
}
路径冲突
当一次请求匹配到多个Pattern,那么就需要选出最接近的Pattern路径。Spring为Pattern和AntPathMatcher提供了选择最接近的路径策略,二者之间逻辑相近,此处只介绍PathPattern。对于PathPattern,Spring提供了PathPattern.SPECIFICITY_COMPARATOR用于对比路径之间的优先级,对比的规则如下:
- null的pattern具有最低优先级。
- 包含通配符的pattern的具有最低优先级(如/**)。
- 如果两个pattern都包含通配符,长度比较长的有更高的优先级。
- 包含越少匹配符号和越少路径变量的pattern有越高的优先级。
- 路径越长的优先级越高。
Spring 5.3之后不再支持.*后缀匹配,默认情况下“/person”就会匹配到所有的 “/person.*”
接受和返回参数的类型
RequestMapping还可以指定接口接受什么类型的参数以及返回什么类型的参数,这通常会在请求头的Content-Type中指定:
@PostMapping(path = "/pets", consumes = "application/json")
public void addPet(@RequestBody Pet pet) {
// ...
}
@GetMapping(path = "/pets/{petId}", produces = "application/json")
@ResponseBody
public Pet getPet(@PathVariable String petId) {
// ...
}
根据参数或Header选择
RequestMapping还支持按照请求的参数或者Header判断是否处理请求。
-
如只接受参数myParam的值为myValue的情况,可以通过如下方式指定:
@GetMapping(path = "/pets/{petId}", params = "myParam=myValue") public void findPet(@PathVariable String petId) { // ... }
-
如只接受请求头中myParam的值为myValue的情况,可以通过如下方式指定:
@GetMapping(path = "/pets", headers = "myHeader=myValue") public void findPet(@PathVariable String petId) { // ... }
编程式注册RequestMapping
我们前面的教程中讲的都是怎么通过@RequestMapping进行路径的映射,使用这种方式会自动把路径映射为添加了注解的方法。这种方式虽然使用很方便,但是灵活性方面有一些欠缺,如果我想要根据Bean的配置信息动态映射路径之间的关系时,注解的方式就无法做到这种需求。Spring提供了一种动态注册RequestMapping的方法,注册示例如下所示:
@Configuration
public class MyConfig {
// 从容器中获取维护映射关系的RequestMappingHandlerMapping和自定义组件UserHandler
@Autowired
public void setHandlerMapping(RequestMappingHandlerMapping mapping, UserHandler handler)
throws NoSuchMethodException {
// 生成路径匹配信息
RequestMappingInfo info = RequestMappingInfo
.paths("/user/{id}").methods(RequestMethod.GET).build();
// 获取需要映射的方法
Method method = UserHandler.class.getMethod("getUser", Long.class);
// 注册路径和方法之间的映射信息
mapping.registerMapping(info, handler, method);
}
}
处理方法
通过RequestMapping映射通常可以把依次请求映射到某个方法,这个方法就是处理方法(Handler Methods)。处理方法的参数和返回值可以使用很多请求中的信息(如@RequestParam, @RequestHeader)等,这些参数支持使用Optional进行封装。
方法参数 | 说明 |
---|---|
WebRequest, NativeWebRequest | 包含了请求参数、请求和Session信息,主要用于Spring框架内部解析参数等操作 |
javax.servlet.ServletRequest, javax.servlet.ServletResponse | Servlet的请求和参数信息 |
javax.servlet.http.HttpSession | 请求的Session信息 |
javax.servlet.http.PushBuilder | 服务器推送是HTTP/2协议中的新特性之一,旨在通过将服务器端的资源推送到浏览器的缓存中来预测客户端的资源需求,以便当客户端发送网页请求并接收来自服务器的响应时,它需要的资源已经在缓存中。这是一项提高网页加载速度的性能增强的功能。在Servlet 4.0中,服务器推送功能是通过PushBuilder实例公开的,此实例是从HttpServletRequest实例中获取的。 |
java.security.Principal | 当前用户的登录信息 |
HttpMethod | 请求的方式,如GET,POST等 |
java.util.Locale | 请求中的国际化信息 |
java.util.TimeZone + java.time.ZoneId | 请求的时区信息 |
java.io.InputStream, java.io.Reader | 用于获取请求原始Body的输入流 |
java.io.OutputStream, java.io.Writer | 用于写回响应的输出流 |
@PathVariable | 路径变量,如”/pets/{petId}”中的petId |
@MatrixVariable | 用分号分割的参数,如GET /pets/42;q=11;r=22 |
@RequestParam | 获取请求中的参数,包含multipart类型的文件 |
@RequestHeader | 请求头信息 |
@CookieValue | 请求中的Cookie信息 |
@RequestBody | 把请求的Body,会使用HttpMessageConverter转为指定的类型的数据。 |
HttpEntity<B> | 类似于@RequestBody |
@RequestPart | 用于获取multipart/form-data中的数据 |
java.util.Map, org.springframework.ui.Model, org.springframework.ui.ModelMap | 获取用于渲染HTML视图的参数 |
@ModelAttribute | 用于获取模型中的属性 |
Errors, BindingResult | 获取参数校验结果信息 |
SessionStatus + class-level @SessionAttributes | Session信息 |
UriComponentsBuilder | 获取匹配过程中的参数信息 |
@SessionAttribute | 获取一个Session属性 |
@RequestAttribute | 获取请求中的属性 |
处理方法也可以支持很多类型的返回值,不同类型的返回有不同的意义。
返回参数 | 说明 |
---|---|
@ResponseBody | @RestController就包含了这个注解,这个注解表示使用HttpMessageConverter把返回值写入Response,不会进行视图解析 |
HttpEntity<B>, ResponseEntity<B> | 和@ResponseBody类似,返回值直接写入Response |
HttpHeaders | 只返回Header不返回body |
String | 按照返回值去查找View,并解析为模型 |
View | 返回一个视图 |
java.util.Map, org.springframework.ui.Model | 用于渲染视图的模型,View由RequestToViewNameTranslator决定 |
@ModelAttribute | 用于渲染视图的模型,View由RequestToViewNameTranslator决定 |
ModelAndView | 返回一个可用的模型视图 |
void | 通常表示没有返回Body |
DeferredResult<V> | 异步返回结果,后文详细介绍 |
Callable<V> | 异步返回结果,后文详细介绍 |
ListenableFuture<V>, java.util.concurrent.CompletionStage<V>, java.util.concurrent.CompletableFuture<V> | 类似于DeferredResult,异步返回调用结果 |
ResponseBodyEmitter, SseEmitter | 异步的把HttpMessageConverter转换后的Body写入Response |
StreamingResponseBody | 把返回异步写入Response |
Reactive types — Reactor, RxJava, or others through ReactiveAdapterRegistry | Flux场景下的异步返回 |
类型转换
网络请求的参数往往是String类型的,而映射到后端时需要转为处理方法需要的数据类型(如@RequestParam, @RequestHeader, @PathVariable, @MatrixVariable 和 @CookieValue)。这种情况下Spring会获取容器内的类型转换服务和属性编辑器进行转换,用户也可以向WebDataBinder中注入自己需要的转换服务。
Matrix参数
Matrix参数其实时RFC3986中关于Url编码的一些规范,Matrix参数之间用分号分割,Matrix参数的多个值之间用逗号分割,例如/cars;color=red,green;year=2012
,多个值之间也允许用分号分割,如color=red;color=green;color=blue
如果一个URL需要包含Matrix参数,那么包含Matrix参数应该是一个路径变量,否则Matrix参数会对路径匹配造成影响:
// GET /pets/42;q=11;r=22
// 最后一段路径必须为路径变量{petId},否则会造成路径匹配失败
@GetMapping("/pets/{petId}")
public void findPet(@PathVariable String petId, @MatrixVariable int q) {
// petId == 42
// q == 11
}
不仅仅URL最后一段可以加Matrix参数,URL的任意一段都可以家Matrix参数,如下所示:
// GET /owners/42;q=11/pets/21;q=22
@GetMapping("/owners/{ownerId}/pets/{petId}")
public void findPet(
@MatrixVariable(name="q", pathVar="ownerId") int q1,
@MatrixVariable(name="q", pathVar="petId") int q2) {
// q1 == 11
// q2 == 22
}
Matrix参数允许设置默认值,用户没有传该参数的时候使用这个默认值:
// GET /pets/42
@GetMapping("/pets/{petId}")
public void findPet(@MatrixVariable(required=false, defaultValue="1") int q) {
// q == 1
}
如果路径中包含很多Matrix参数,一个一个接收可能比较麻烦,我们可以通过MultiValueMap用集合的形式去接收:
// GET /owners/42;q=11;r=12/pets/21;q=22;s=23
@GetMapping("/owners/{ownerId}/pets/{petId}")
public void findPet(
@MatrixVariable MultiValueMap<String, String> matrixVars,
@MatrixVariable(pathVar="petId") MultiValueMap<String, String> petMatrixVars) {
// matrixVars: ["q" : [11,22], "r" : 12, "s" : 23]
// petMatrixVars: ["q" : 22, "s" : 23]
}
如果你需要在程序中使用Matrix参数,需要的配置
UrlPathHelper
的removeSemicolonContent=false
。
@RequestParam
@RequestParam用于把请求中的参数(查询参数或者表单参数)绑定到对应的方法参数上,默认情况下不允许请求参数中不包含指定的参数,不过用户可以指定required=false
去允许设置请求参数到对应的方法参数。如果方法的参数类型不为String类型,Spring会自动进行类型转换。当@RequestParam注解的参数类型为Map<String, String>并且@RequestParam没有指定参数名称的时候,Spring会把所有的参数注入到Map中。
@Controller
@RequestMapping("/pets")
public class EditPetForm {
// ...
@GetMapping
public String setupForm(@RequestParam("petId") int petId, Model model) {
Pet pet = this.clinic.loadPet(petId);
model.addAttribute("pet", pet);
return "petForm";
}
// ...
}
@RequestHeader
一次Http请求往往会包含请求头和Body两部分,我们可以通过@RequestHeader把请求头和处理方法的参数进行绑定,@RequestHeader同样支持Map,假设一次请求有如下的头:
Host localhost:8080
Accept text/html,application/xhtml+xml,application/xml;q=0.9
Accept-Language fr,en-gb;q=0.7,en;q=0.3
Accept-Encoding gzip,deflate
Accept-Charset ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive 300
如果我们需要在方法中获取Accept-Encoding和Keep-Alive标签,我们可以通过如下代码获取:
@GetMapping("/demo")
public void handle(
@RequestHeader("Accept-Encoding") String encoding,
@RequestHeader("Keep-Alive") long keepAlive) {
//...
}
@CookieValue
如果我们需要获取一次请求中的cookie信息,我们可以通过@CookieValue获取,获取方法如下所示:
@GetMapping("/demo")
public void handle(@CookieValue("JSESSIONID") String cookie) {
//...
}
@ModelAttribute
@ModelAttribute可以把请求中的参数映射为对象,然后传递给对应的方法。
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute Pet pet) {
// method logic...
}
上面的例子中,请求参数可以来自pet
可以来自以下几种途径:
- 在请求预处理的过程中添加的@ModelAttribute属性中的pet;
- 从HttpSession中的@SessionAttributes属性中查找pet;
- 从请求参数或者pathVariable中查找pet属性;
- 使用默认的构造函数初始化数据。
@PutMapping("/accounts/{account}")
public String save(@ModelAttribute("account") Account account) {
// ...
}
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute("pet") Pet pet, BindingResult result) {
if (result.hasErrors()) {
return "petForm";
}
// ...
}
@ModelAttribute
public AccountForm setUpForm() {
return new AccountForm();
}
@ModelAttribute
public Account findAccount(@PathVariable String accountId) {
return accountRepository.findOne(accountId);
}
@PostMapping("update")
public String update(@Valid AccountForm form, BindingResult result,
@ModelAttribute(binding=false) Account account) {
// ...
}
@SessionAttributes 和 @SessionAttribute
@SessionAttributes用于在多个请求之间共享Session数据,该注解只能加载类之上。在第一次请求的时候,会把Session数据放入SessionAttributes中,Session结束的时候清除数据。
@Controller
@SessionAttributes("pet") // 把数据放入Session中
public class EditPetForm {
// 从Session中查询数据
@PostMapping("/pets/{id}")
public String handle(Pet pet, BindingResult errors, SessionStatus status) {
if (errors.hasErrors) {
// ...
}
// 清空Session中的数据.
status.setComplete();
// ...
}
}
}
如果Session的属性不由Controller管理,而是其它组件管理(如Filter管理),我们就可以使用@SessionAttribute去把Session中的数据和处理方法中的参数进行绑定。
@RequestMapping("/")
public String handle(@SessionAttribute User user) {
// ...
}
@RequestAttribute
@RequestAttribute和@SessionAttributes类似,一个是请求级别的,一个是Session级别的,此处不做详细介绍。
@GetMapping("/")
public String handle(@RequestAttribute Client client) {
// ...
}
Multipart参数
我们在前面的文章中说过,DispatcherServlet中会包含MultipartResolver组件,如果一次请求的数据为multipart/form-data
类型,DispatcherServlet会把上传的文件解析为MultipartFile格式的文件。Servlet3中也支持使用 javax.servlet.http.Part代替MultipartFile接收文件,上传多个文件的时候可以使用列表或者Map获取参数。
@Controller
public class FileUploadController {
@PostMapping("/form")
public String handleFormUpload(@RequestParam("name") String name,
@RequestParam("file") MultipartFile file) {
if (!file.isEmpty()) {
byte[] bytes = file.getBytes();
// store the bytes somewhere
return "redirect:uploadSuccess";
}
return "redirect:uploadFailure";
}
}
Multipart也可以把需要接收的文件封装为对象进行接收。
class MyForm {
private String name;
private MultipartFile file;
// ...
}
@Controller
public class FileUploadController {
@PostMapping("/form")
public String handleFormUpload(MyForm form, BindingResult errors) {
if (!form.getFile().isEmpty()) {
byte[] bytes = form.getFile().getBytes();
// store the bytes somewhere
return "redirect:uploadSuccess";
}
return "redirect:uploadFailure";
}
}
除了通过浏览器上传文件,我们还可以通过RestFul方式以Json的格式上传文件:
POST /someUrl
Content-Type: multipart/mixed
--edt7Tfrdusa7r3lNQc79vXuhIIMlatb7PQg7Vp
Content-Disposition: form-data; name="meta-data"
Content-Type: application/json; charset=UTF-8
Content-Transfer-Encoding: 8bit
{
"name": "value"
}
--edt7Tfrdusa7r3lNQc79vXuhIIMlatb7PQg7Vp
Content-Disposition: form-data; name="file-data"; filename="file.properties"
Content-Type: text/xml
Content-Transfer-Encoding: 8bit
... File Data ...
@PostMapping("/")
public String handle(@RequestPart("meta-data") MetaData metadata,
@RequestPart("file-data") MultipartFile file) {
// ...
}
@RequestBody和HttpEntity
@RequestBody应该是日常开发中使用最多的参数之一了,我们可以通过@RequestBody把请求中的Body和处理方法中的参数对象进行绑定,Spring会调用HttpMessageConverter服务把请求中的数据反序列化为处理方法中的参数对象。@RequestBody还可以和@Validated注解组合进行使用,如果校验失败会抛出异常或者交给用户处理校验异常信息。
@PostMapping("/accounts")
public void handle(@Valid @RequestBody Account account, BindingResult result) {
// ...
}
HttpEntity和@RequestBody的原理类似,不过会把请求体封装到HttpEntity中。
@PostMapping("/accounts")
public void handle(HttpEntity<Account> entity) {
// ...
}
@ResponseBody和ResponseEntity
@ResponseBody表示会把返回值通过HttpMessageConverter直接序列化为String写入Response,我们平时使用比较多的@RestController就是由@ResponseBody和@Controller组成。
@GetMapping("/accounts/{id}")
@ResponseBody
public Account handle() {
// ...
}
ResponseEntity和@ResponseBody,不过返回的基础上会包含状态码和返回头等信息。
@GetMapping("/something")
public ResponseEntity<String> handle() {
String body = ... ;
String etag = ... ;
return ResponseEntity.ok().eTag(etag).build(body);
}
JSON Views
Spring内置了对JacksonJSON的支持,并且支持Jackson的Json序列化视图,在使用@ResponseBody和ResponseEntity返会数据时,可以按照@JsonView来指定Json序列化时需要显示的字段。
@RestController
public class UserController {
@GetMapping("/user")
@JsonView(User.WithoutPasswordView.class)
public User getUser() {
return new User("eric", "7!jd#h23");
}
}
public class User {
public interface WithoutPasswordView {};
public interface WithPasswordView extends WithoutPasswordView {};
private String username;
private String password;
public User() {
}
public User(String username, String password) {
this.username = username;
this.password = password;
}
@JsonView(WithoutPasswordView.class)
public String getUsername() {
return this.username;
}
@JsonView(WithPasswordView.class)
public String getPassword() {
return this.password;
}
}
我们也可以通过编程的方式实现对象的不同视图的序列化,使用方法如下所示:
@RestController
public class UserController {
@GetMapping("/user")
public MappingJacksonValue getUser() {
User user = new User("eric", "7!jd#h23");
MappingJacksonValue value = new MappingJacksonValue(user);
value.setSerializationView(User.WithoutPasswordView.class);
return value;
}
}
对于基于View的解决方案,我们可以在Model中添加对应的对象以及Json序列化视图,使用的示例如下所示:
@Controller
public class UserController extends AbstractController {
@GetMapping("/user")
public String getUser(Model model) {
model.addAttribute("user", new User("eric", "7!jd#h23"));
model.addAttribute(JsonView.class.getName(), User.WithoutPasswordView.class);
return "userView";
}
}
Model对象
Spring中的model对象负责在控制器和展现数据的视图之间传递数据。Spring提供了@ModelAttribute去获取和写入Model对象的属性,@ModelAttribute有多种使用方式:
- 在处理方法的入参上添加@ModelAttribute,可以获取WebDataBinder中已经有的Model中的属性值。
- 在类上(如Controller)添加@ModelAttribute注解,则会为所有的请求初始化模型。
- 在处理方法的返回值上添加@ModelAttribute,表示返回值会作为模型的属性。
@ModelAttribute
public void populateModel(@RequestParam String number, Model model) {
model.addAttribute(accountRepository.findAccount(number));
// add more ...
}
@ModelAttribute
public Account addAccount(@RequestParam String number) {
return accountRepository.findAccount(number);
}
DataBinder
前面我们讲了很多如何把请求参数和处理方法入参进行绑定的注解或者类型,并且知道请求参数需要经过类型转换才能转为对应类型的数据。然而注解只是一个标记,并不会实际执行参数绑定和类型转换操作,Spring中必定有一个组件进行参数绑定和类型转换,这个组件就是WebDataBinder。WebDataBinder有一下作用:
- 将请求中的参数和处理方法参数进行绑定;
- 把请求中Spring类型的数据转为处理方法的参数类型;
- 对渲染表单的数据进行格式化。
Spring给用户提供了修改WebDataBinder的接口,用户可以在Controller中定义被@InitBinder注解的方法,在方法中修改WebDataBinder的定义:
@Controller
public class FormController {
@InitBinder
public void initBinder(WebDataBinder binder) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
dateFormat.setLenient(false);
binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
}
// ...
}
异常处理
在关于DispatcherServlet相关的章节中,我们知道了DispatcherServlet包含了异常解析组件,当异常发生的时候会对异常进行解析。日常开发中使用比较多的异常处理组件是ExceptionHandlerExceptionResolver,用于在遇到异常时,使用带有@ExceptionHandler注解的方法处理对应的异常,该方法可以定义中Controller或者ControllerAdvice中。
@Controller
public class SimpleController {
// ...
@ExceptionHandler
public ResponseEntity<String> handle(IOException ex) {
// ...
}
@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(Exception ex) {
// ...
}
}
如果我们需要定义很多@ExceptionHandler,我们可以选择在@ControllerAdvice中定义,而不是在每个Controller中定义。
如果一个异常匹配到多个@ExceptionHandler,Spring会尝试使用距离异常继承体系最近的@ExceptionHandler去处理这个异常。
Controller Advice
如果我们需要定义全局的@InitBinder或者@ExceptionHandler,那我们就不应该在Controller中定义这些方法。 Spring提供了@ControllerAdvice用于添加全局配置:
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
我是御狐神,欢迎大家关注我的微信公众号:wzm2zsd
本文最先发布至微信公众号,版权所有,禁止转载!