首页 文章详情

微服务SpringCloud项目:初步整合gateway进行限流、token过滤等操作

愿天堂没有BUG | 759 2021-10-20 19:18 0 0 0
UniSMS (合一短信)

前言

心态好了,就没那么累了。心情好了,所见皆是明媚风景。
复制代码

“一时解决不了的问题,那就利用这个契机,看清自己的局限性,对自己进行一场拨乱反正。”正如老话所说,一念放下,万般自在。如果你正被烦心事扰乱心神,不妨学会断舍离。断掉胡思乱想,社区垃圾情绪,离开负面能量。心态好了,就没那么累了。心情好了,所见皆是明媚风景。

🚓摘要


Spring Cloud GatewaySpringBoot 应用提供了 API 网关支持,具有强大的智能路由与过滤器功能,本文将对其用法进行详细介绍。

SpringCloudGatewaySpringCloud 新推出的网关框架,比较于上一代 Zuul,功能和性能有很大的提升。Zuul1.x 采用的是阻塞多线程方式,也就是一个线程处理一个连接请求,高并发情况下性能较差,即使是 Zuul2.x 虽然做到了非阻塞,但是面对连续跳票,看起来 Zuul 要被抛弃了。取而代之的是SpringCloudGatewaySpringCloudGateway 是基于 Webflux ,是一个非阻塞异步的框架,性能上有很大提升,而且包含了 Zuul 的所有功能,可以从 Zuul 无缝切换到 SpringCloudGateway


1. Gateway 简介

Gateway是在 Spring生态系统 之上构建的 API 网关服务,基于 Spring 5,Spring Boot 2和 Project Reactor 等技术。Gateway 旨在提供一种简单而有效的方式来对API 进行路由,以及提供一些强大的过滤器功能, 例如:熔断、限流、重试 等。

Spring Cloud Gateway 具有如下特性:

  • 基于 Spring Framework 5, Project Reactor 和 Spring Boot 2.0 进行构建;

  • 动态路由:能够匹配任何请求属性;

  • 可以对路由指定 Predicate(断言)和 Filter(过滤器)

  • 集成 Hystrix 的断路器功能;

  • 集成 Spring Cloud 服务发现功能;

  • 易于编写的 Predicate(断言)和 Filter(过滤器)

  • 请求限流功能;

  • 支持路径重写。


2. 相关概念

  • Route(路由):路由是构建网关的基本模块,它由ID,目标URI,一系列的断言和过滤器组成,如果断言为true则匹配该路由;

  • Predicate(断言):指的是 Java 8Function Predicate。输入类型是 Spring 框架中的 ServerWebExchange 。这使开发人员可以匹配HTTP请求中的所有内容,例如请求头或请求参数。如果请求与断言相匹配,则进行路由;

  • Filter(过滤器):指的是Spring框架中 GatewayFilter 的实例,使用过滤器,可以在请求被路由前后对请求进行修改。


3. 引入 gateway 依赖

pom.xml 中添加相关依赖(引入 SpringCloudGateway 需要的 POM ,记得引入 actuator 组件,否则服务发现中心会认为服务不在线,导致网关无法路由到服务)

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
复制代码

下面是我用到的依赖

<?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>
<artifactId>DreamChardonnayCloud</artifactId>
<groupId>com.cyj.dream</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.cyj.dream.gateway</groupId>
<artifactId>dream-gateway</artifactId>
<version>1.0-SNAPSHOT</version>
<name>dream-gateway</name>
<packaging>jar</packaging>
<description>网关</description>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>

<dependencies>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${admin-server.version}</version>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<!-- SpringBoot 监控客户端 -->
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${spring-boot-admin.version}</version>
</dependency>

<!-- 引入gateway -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<version>${gateway.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
<version>${gateway.version}</version>
</dependency>

<!-- 引入数据库密码加密 -->
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>${jasypt.version}</version>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>${common-pool.version}</version>
</dependency>

<!-- 引入redis数据库依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!-- <exclusions>
<exclusions>
<exclusion>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</exclusion>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions> -->

</dependency>

<!-- 引入验证码 -->
<dependency>
<groupId>com.cyj.dream.captcha</groupId>
<artifactId>dream-common-captcha</artifactId>
<version>1.0-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- 引入核心core -->
<dependency>
<groupId>com.cyj.dream.core</groupId>
<artifactId>dream-common-core</artifactId>
<version>1.0-SNAPSHOT</version>
<!-- https://blog.csdn.net/qq_41686190/article/details/107280990 -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>com.cyj.dream.swagger</groupId>
<artifactId>dream-swagger</artifactId>
<version>1.0-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal><!--可以把依赖的包都打包到生成的Jar包中 -->
</goals>
<!--可以生成不含依赖包的不可执行Jar包 -->
<configuration>
<classifier>exec</classifier>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>


</project>
复制代码

4. 启动类如下:

package com.cyj.dream.gateway;

import cn.hutool.core.date.DateUtil;
import com.cyj.dream.swagger.annotation.EnableDreamSwagger2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

/**
* @Description: 网关gateway启动类
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/
@Slf4j
@EnableDreamSwagger2
@EnableDiscoveryClient
@SpringBootApplication(exclude={DataSourceAutoConfiguration.class})
public class DreamGatewayApplication {

/**
* 项目启动方法
*
* @param args the input arguments
* @date 2021-9-26
* @author Sunny Chen
*/
public static void main(String[] args) {
log.info("梦享云--网关gateway开始启动ing!======>{}", DateUtil.now());
SpringApplication application = new SpringApplication(DreamGatewayApplication.class);
// 该设置方式
application.setWebApplicationType(WebApplicationType.REACTIVE);
application.run(args);
log.info("梦享云--网关gateway启动成功ing.......!======>{}", DateUtil.now());
}

}
复制代码

5. yml 配置

这里我只展示关于 gateway 的配置其他配置请自行处理

spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allow-credentials: true
allowed-originPatterns: "*"
allowed-methods: "*"
allowed-headers: "*"
add-to-simple-url-handler-mapping: true
locator:
enabled: true
routes:
# 认证中心
- id: dream-auth
uri: lb://dream-auth
predicates:
- Path=/auth/**
filters:
# 验证码处理
- ValidateCodeGatewayFilter
# 前端密码解密
# - PasswordDecoderFilter
# 代码生成模块
- id: dream-codegen
uri: lb://dream-codegen
predicates:
- Path=/dsconf/**
# 文件管理模块
- id: dream-file-management
uri: lb://dream-file-management
predicates:
- Path=/file/**
# springboot2.X版本需要如下配置,设置大文件处理的大小
servlet:
multipart:
# 1GB
max-file-size: 1024MB
max-request-size: 1024MB
# 允许覆盖bean定义
main:
allow-bean-definition-overriding: true
# redis 配置
redis:
# redis服务地址
host:
# Redis服务器连接端口
port:
# Redis服务器连接密码(默认为空)
password:
# 使用的库
database: 0
lettuce:
pool:
# 连接池最大连接数(使用负值表示没有限制)
max-active: 300
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: 300ms
# 连接池中的最大空闲连接
max-idle: 16
# 连接池中的最小空闲连接
min-idle: 8
# 连接超时时间(毫秒)
timeout: 60000

gateway:
encode-key: 'thanks,dreamChardonnay'
ignore-clients:
# - test2
allow-paths:
- /dreamAuth/**
- /auth/**
- /oauth/**
- /login
- /v2/**
- /allowFile/**
- /sse/**
- /form/**
复制代码

6. 路由配置信息

我在路由里加了图片验证操作,各位可以自行查找添加进来

/**
* @Description: 路由配置信息
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.config
* @Author: ChenYongJia
* @CreateTime: 2021-09-27 12:50
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Slf4j
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class RouterFunctionConfiguration {

private final ImageCodeHandler imageCodeHandler;

@Bean
public RouterFunction routerFunction() {
return RouterFunctions.route(
RequestPredicates.path("/code").and(RequestPredicates.accept(MediaType.TEXT_PLAIN)), imageCodeHandler);
}

}
复制代码

7. 路由限流

/**
* @Description: 路由限流
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.config
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Configuration(proxyBeanMethods = false)
public class RateLimiterConfiguration {

@Bean(value = "remoteAddrKeyResolver")
public KeyResolver remoteAddrKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}

}
复制代码

8. 网关配置

GatewayConfigProperties.java 网关配置文件

/**
* @Description: 网关配置文件
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.config
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Data
@RefreshScope
@ConfigurationProperties("gateway")
public class GatewayConfigProperties {

/**
* 网关解密登录前端密码 秘钥 {@link PasswordDecoderFilter}
*/

public String encodeKey;

/**
* 权限放行的地址
*/

private List<String> allowPaths;

}
复制代码

GatewayConfiguration.java 网关配置

/**
* @Description: 网关配置
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.config
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(GatewayConfigProperties.class)
public class GatewayConfiguration {

@Bean
public PasswordDecoderFilter passwordDecoderFilter(GatewayConfigProperties configProperties) {
return new PasswordDecoderFilter(configProperties);
}

@Bean
public GlobalExceptionHandler globalExceptionHandler(ObjectMapper objectMapper) {
return new GlobalExceptionHandler(objectMapper);
}

}

复制代码

8. filter拦截器配置

PasswordDecoderFilter 密码相关

/**
* @Description: 密码解密过滤器
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.filter
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Slf4j
@RequiredArgsConstructor
public class PasswordDecoderFilter extends AbstractGatewayFilterFactory {

private static final String PASSWORD = "password";

private static final String QRCODE = "QRCode";

private static final String KEY_ALGORITHM = "AES";

private final GatewayConfigProperties configProperties;

private static String decryptAES(String data, String pass) {
AES aes = new AES(Mode.CBC, Padding.NoPadding, new SecretKeySpec(pass.getBytes(), KEY_ALGORITHM),
new IvParameterSpec(pass.getBytes()));
byte[] result = aes.decrypt(Base64.decode(data.getBytes(StandardCharsets.UTF_8)));
return new String(result, StandardCharsets.UTF_8);
}

@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();

// 不是登录请求,直接向下执行
if (!StrUtil.containsAnyIgnoreCase(request.getURI().getPath(), SecurityConstants.OAUTH_TOKEN_URL)) {
return chain.filter(exchange);
}

URI uri = exchange.getRequest().getURI();
String queryParam = uri.getRawQuery();
Map<String, String> paramMap = HttpUtil.decodeParamMap(queryParam, CharsetUtil.CHARSET_UTF_8);
//解析密码
String password = paramMap.get(PASSWORD);
if (StrUtil.isNotBlank(password)) {
try {
password = decryptAES(password, configProperties.getEncodeKey());
}
catch (Exception e) {
log.error("密码解密失败:{}", password);
return Mono.error(e);
}
paramMap.put(PASSWORD, password.trim());
}
//解析QRCode
String QRCode= paramMap.get(QRCODE);
if (StrUtil.isNotBlank(QRCode)) {
try {
QRCode = decryptAES(QRCode, configProperties.getEncodeKey());
}
catch (Exception e) {
log.error("QRCode解密失败:{}", QRCode);
return Mono.error(e);
}
paramMap.put(QRCODE, QRCode.trim());
}

URI newUri = UriComponentsBuilder.fromUri(uri).replaceQuery(HttpUtil.toParams(paramMap)).build(true)
.toUri();

ServerHttpRequest newRequest = exchange.getRequest().mutate().uri(newUri).build();
return chain.filter(exchange.mutate().request(newRequest).build());
};
}

}
复制代码

全局拦截器,作用所有的微服务

  • 对请求头中参数进行处理 from 参数进行清洗

  • 重写StripPrefix = 1,支持全局

  • 支持swagger添加X-Forwarded-Prefix header (F SR2 已经支持,不需要自己维护)

package com.cyj.dream.gateway.filter;

import com.cyj.dream.core.constant.SecurityConstants;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.Arrays;
import java.util.stream.Collectors;

import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl;

/**
* @Description: 全局拦截器,作用所有的微服务
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.filter
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

public class RequestGlobalFilter implements GlobalFilter, Ordered {

/**
* Process the Web request and (optionally) delegate to the next {@code WebFilter}
* through the given {@link GatewayFilterChain}.
* @param exchange the current server exchange
* @param chain provides a way to delegate to the next filter
* @return {@code Mono<Void>} to indicate when request processing is complete
*/

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 清洗请求头中from 参数
ServerHttpRequest request = exchange.getRequest().mutate()
.headers(httpHeaders -> httpHeaders.remove(SecurityConstants.FROM)).build();

// 2. 重写StripPrefix
addOriginalRequestUrl(exchange, request.getURI());
String rawPath = request.getURI().getRawPath();
String newPath = "/" + Arrays.stream(StringUtils.tokenizeToStringArray(rawPath, "/")).skip(1L)
.collect(Collectors.joining("/"));
ServerHttpRequest newRequest = request.mutate().path(newPath).build();
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newRequest.getURI());

return chain.filter(exchange.mutate().request(newRequest.mutate().build()).build());
}

@Override
public int getOrder() {
return -1000;
}
}
复制代码

token 验证过滤器

注意如果线上使用看下代码把本地注解注掉

/**
* @Description: token验证过滤器
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.filter
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Component
@RefreshScope
public class TokenFilter implements GlobalFilter, Ordered {
@Resource
private RedisTemplate redisTemplate;
@Autowired
private ObjectMapper objectMapper;
@Resource
private GatewayConfigProperties gatewayConfigProperties;
/**
* 可以用于验证用户登录状态,设置某部分请求信息等。
*/

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String apiPath=request.getPath().toString();

// 如果不是GET请求,判断是否已经登录,token是否过期
// 获取cookie中的token令牌
HttpHeaders headers = exchange.getRequest().getHeaders();
// List<HttpCookie> cookies = multiValueMap.get("token");

if ( headers.size() != 0) {
String token = headers.getFirst("Authorization");
//token 不为空 放行
/*if (StrUtil.isNotBlank(token)) {
return chain.filter(exchange);
}else{
if(this.isAllow(apiPath)){
return chain.filter(exchange);
}
}*/

// 本地不需要token
return chain.filter(exchange);
}
return response.writeWith(Mono.create(monoSink -> {
try {
byte[] bytes = objectMapper.writeValueAsBytes(ResponseUtil.error("登录过期请先登录"));
DataBuffer dataBuffer = response.bufferFactory().wrap(bytes);

monoSink.success(dataBuffer);
} catch (JsonProcessingException jsonProcessingException) {
monoSink.error(jsonProcessingException);
}
}));
}

@Override
public int getOrder() {
return 0;
}

/**
* 正则匹配地址
*
* @param path
* @return
*/

private boolean isAllow(String path) {
Pattern pattern = null;
String orginalUrl = path.split("[?]")[0];
for (String regex : getRegexList()) {
pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(orginalUrl).matches()) {
return true;
}
}
return false;
}

/**
* 获取正则匹配规则list
*
* @return
*/

private List<String> getRegexList() {
//第一次加载这个,以及配置修改的时候加载这个(这个还没法实现,只能每次都加载)
List<String> regexList=null;
Object regexListJson = redisTemplate.opsForValue().get("regexList");
if (regexListJson == null) {
regexList = new ArrayList<String>();
}else{
regexList = JSON.parseObject((String)regexListJson,new TypeReference<ArrayList<String>>() {
});
return regexList;
}
for (String url : gatewayConfigProperties.getAllowPaths()) {
StringBuilder regex = new StringBuilder("\\S*").append(url.replace("/**", "\\S*")).append("\\S*");
regexList.add(regex.toString());
}
redisTemplate.opsForValue().set("regexList",JSON.toJSONString(regexList));
return regexList;
}

}
复制代码

9. 处理器

网关异常通用处理器,只作用在 webflux 环境下 , 优先级低于{@link ResponseStatusExceptionHandler}执行

/**
* @Description: 网关异常通用处理器,只作用在webflux 环境下 , 优先级低于 {@link ResponseStatusExceptionHandler} 执行
* @BelongsProject: DreamChardonnay
* @BelongsPackage: com.cyj.dream.gateway.handler
* @Author: ChenYongJia
* @CreateTime: 2021-09-27
* @Email: chen87647213@163.com
* @Version: 1.0
*/

@Slf4j
@Order(-1)
@RequiredArgsConstructor
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

private final ObjectMapper objectMapper;

@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();

if (response.isCommitted()) {
return Mono.error(ex);
}

// header set
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
if (ex instanceof ResponseStatusException) {
response.setStatusCode(((ResponseStatusException) ex).getStatus());
}

return response.writeWith(Mono.fromSupplier(() -> {
DataBufferFactory bufferFactory = response.bufferFactory();
try {
return bufferFactory.wrap(objectMapper.writeValueAsBytes(ResponseUtil.error(ex.getMessage())));
}
catch (JsonProcessingException e) {
log.error("Error writing response", ex);
return bufferFactory.wrap(new byte[0]);
}
}));
}

}
复制代码

10. 简单说下--结果我就不验证了

这次的代码,涉及到了比较多的引入,我就没有一一放出来给到大家,如果想看源码就等等我发到 git 吧,实际使用需要你去组合借鉴,切忌复制粘贴哦!!!!!!


PS:最近输出的文章以实用耐造为主,希望对你有所帮助而不是长篇大论全是理论实战弱鸡,最后感谢大家耐心观看完毕,留个点赞收藏是您对我最大的鼓励!


作者:Sunny_Chen
链接:https://juejin.cn/post/7019100839826423839
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter