背景

为了增强产品安全性,计划对应用网关进行改造,主要是出入参经过网关时需要进行加解密操作,保证请求数据在网络传输过程中不会泄露或篡改。

考虑到密钥的安全性,每个用户登录都会签发独立的密钥对。同时摒弃了对称加密算法,使用国密非对称的SM2算法进行参数加解密。

网关加解密全流程时序图

难点

先说下开发过程中遇到的一些困难,后面再看代码就知道为什么这么写。

1、网上有价值可供参考的代码不多,这也是为什么要写这边博客的原因,网上现有代码大部分都是互相照搬的,实测过程会发现有很多问题,比如ServerHttpRequestDecorator要重复new很多遍。

2、由于Gateway是基于WebFlux的非阻塞线程模型开发的,在读取RequestBody时可能会出现读取不完整的问题,而且是偶发现象,同样的问题在重写ResponseBody时也会遇到。

3、性能问题,SM2算法是基于bcprov-jdk15on开源库,加解密过程需要对密钥对进行缓存,如果通过16进制字符串进行序列化耗时过长,会造成网关性能瓶颈。

SM2

先说SM2加解密算法这块。

用的是全球最大同性交友网站开源的一个项目,对SM2加解密操作进行了一些封装,项目地址:https://github.com/ZZMarquis/gmhelper

因为每个用户登录时都会签发密钥对,所以每次加解密需要获取用户对应的密钥对后再进行参数加解密操作。

为了避免每次通过密钥对字符串创建密钥对象增加代码执行耗时,用户密钥对使用protostuff序列化为字符串后en缓存在Redis中,需要使用的时候直接从Redis中读取出来反序列化为密钥对象即可,这一步大大提升了代码性能。

该部分代码如下:

pom.xml

<dependency>
<groupId>org.zz</groupId>
<artifactId>gmhelper</artifactId>
<version>1.0.0</version>
</dependency> <dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.8.0</version>
</dependency> <dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.8.0</version>
</dependency>

密钥对PO对象

public class SM2Key implements Serializable {

    private static final long serialVersionUID = 8273826788748051389L;
/**
* 后端加密公钥,对应私钥由前端持有(webPrivateKey)
*/

private ECPublicKeyParameters serverPublicKey; /**
* 后端解密私钥,对应公钥由前端持有(webPublicKey)
*/
private ECPrivateKeyParameters serverPrivateKey; /**
* 前端加密公钥,对应私钥由后端持有(serverPrivateKey)
*/
private String webPublicKey; /**
* 前端解密私钥,对应公钥由后端持有(serverPublicKey)
*/
private String webPrivateKey; public static SM2Key build() {
return new SM2Key();
} public static SM2Key build(String protostuffHex) {
final byte[] protostuffBytes = ByteUtils.fromHexString(protostuffHex);
Schema schema = RuntimeSchema.getSchema(SM2Key.class);
SM2Key key = RuntimeSchema.getSchema(SM2Key.class).newMessage();
GraphIOUtil.mergeFrom(protostuffBytes, key, schema);
return key;
} public ECPublicKeyParameters getServerPublicKey() {
return serverPublicKey;
} public void setServerPublicKey(ECPublicKeyParameters serverPublicKey) {
this.serverPublicKey = serverPublicKey;
} public ECPrivateKeyParameters getServerPrivateKey() {
return serverPrivateKey;
} public void setServerPrivateKey(ECPrivateKeyParameters serverPrivateKey) {
this.serverPrivateKey = serverPrivateKey;
} public String getWebPublicKey() {
return webPublicKey;
} public void setWebPublicKey(String webPublicKey) {
this.webPublicKey = webPublicKey;
} public String getWebPrivateKey() {
return webPrivateKey;
} public void setWebPrivateKey(String webPrivateKey) {
this.webPrivateKey = webPrivateKey;
} /**
* 对象序列化
* @return
*/
public String toProtostuffString() {
LinkedBuffer buffer = LinkedBuffer.allocate();
try {
Schema<SM2Key> schema = RuntimeSchema.getSchema(SM2Key.class);
final byte[] protostuff = GraphIOUtil.toByteArray(this, schema, buffer);
return ByteUtils.toHexString(protostuff);
} finally {
buffer.clear();
}
} }

密钥对签发工具类

public class SM2KeyUtil {

    /**
* 生成前后端加解密密钥对
* @return
*/
public static SM2Key generate() { // 构建前后端密钥对
SM2Key key = SM2Key.build(); AsymmetricCipherKeyPair keyPair;
ECPrivateKeyParameters privateKey;
ECPublicKeyParameters publicKey; keyPair = SM2Util.generateKeyPairParameter();
privateKey = (ECPrivateKeyParameters) keyPair.getPrivate();
publicKey = (ECPublicKeyParameters) keyPair.getPublic();
// 后端加密所需公钥
key.setServerPublicKey(publicKey);
// 前端解密所需私钥
key.setWebPrivateKey(ByteUtils.toHexString(privateKey.getD().toByteArray())); keyPair = SM2Util.generateKeyPairParameter();
privateKey = (ECPrivateKeyParameters) keyPair.getPrivate();
publicKey = (ECPublicKeyParameters) keyPair.getPublic();
// 后端解密所需私钥
key.setServerPrivateKey(privateKey);
// 前端加密所需公钥
key.setWebPublicKey(ByteUtils.toHexString(publicKey.getQ().getEncoded(false))); return key; } }

通过SM2Key.toProtostuffString()方法获得序列化字符串并写入Redis中

全局拦截器

全局拦截器配置类

/**
* 网关加解密配置类
* @author changxy
*/
@Configuration
@ConditionalOnProperty(value = "secret.enabled", havingValue = "true", matchIfMissing = true)
public class SecretConfiguration { private static final Logger log = LoggerFactory.getLogger(SecretConfiguration.class); /**
* 免加密接口配置
*/
public static final String EXCLUDE_PATH_CONFIG_KEY = "#{'${secret.excluded.paths}'.split(',')}"; /**
* 注册入参解密全局拦截器
* @param secretFormatterAdapter 加解密格式化适配器
* @param decryptRequestBodyFilterFactory 入参解密拦截器工厂,主要为了读取Body
* @param requestBodyDecryptRewriter RequestBody参数解密RewriteFunction
* @return
*/
@Bean
public DecryptParameterFilter decryptParameterFilter(
@Autowired SecretFormatterAdapter secretFormatterAdapter,
@Autowired ModifyRequestBodyGatewayFilterFactory decryptRequestBodyFilterFactory,
@Autowired RequestBodyDecryptRewriter requestBodyDecryptRewriter
) {
log.info("初始化入参解密全局拦截器");
return new DecryptParameterFilter(secretFormatterAdapter, decryptRequestBodyFilterFactory, requestBodyDecryptRewriter);
} /**
* 注册出参加密拦截器
* !!!!免加密配置项中的接口出参不进行加密处理!!!!
* @param secretFormatterAdapter 加解密格式化适配器
* @param encryptFilterFactory 出参加密拦截器工厂,对Content-Type为JSON的响应内容加密处理
* @param jsonEncryptRewriter ResponseBody参数加密RewriteFunction
* @param excludedPaths 免加密接口配置
* @return
*/
@Bean
public EncryptResponseFilter encryptResponseFilter(
@Autowired SecretFormatterAdapter secretFormatterAdapter,
@Autowired ModifyResponseBodyGatewayFilterFactory encryptFilterFactory,
@Autowired ResponseJSONEncryptRewriter jsonEncryptRewriter,
@Value(EXCLUDE_PATH_CONFIG_KEY) List<String> excludedPaths
) {
log.info("初始化出参加密全局拦截器");
return new EncryptResponseFilter(secretFormatterAdapter, encryptFilterFactory, jsonEncryptRewriter, excludedPaths);
} /**
* 入参解密拦截器工厂,主要为了读取Body
* @param secretFormatterAdapter 加解密格式化适配器
* @return
*/
@Bean
RequestBodyDecryptRewriter requestBodyDecryptRewrite(@Autowired SecretFormatterAdapter secretFormatterAdapter) {
return new RequestBodyDecryptRewriter(secretFormatterAdapter);
} /**
* 出参加密拦截器工厂,对Content-Type为JSON的响应内容加密处理
* @param secretFormatterAdapter 加解密格式化适配器
* @return
*/
@Bean
ResponseJSONEncryptRewriter responseJSONEncryptRewriter(@Autowired SecretFormatterAdapter secretFormatterAdapter) {
return new ResponseJSONEncryptRewriter(secretFormatterAdapter);
} }

全局入参解密拦截器,为了安全起见,保留关键代码,部分常量被移除。

DecryptedServerHttpRequestDecorator通过重写URI实现querystring部分参数的解密处理,同时在路由转发前增加secret请求头。

RequestBodyDecryptRewriter是RewriteFunction的实现类,主要读取RequestBody内容进行解密、重写操作,这里使用RewriteFunction可以获取完整的Body内容。

public class DecryptParameterFilter implements GlobalFilter, Ordered {

    private final static Logger log = LoggerFactory.getLogger(DecryptParameterFilter.class);

    protected static final List<MediaType> ENCRYPT_MEDIA_TYPES = Arrays.asList(MediaType.APPLICATION_JSON,
MediaType.APPLICATION_JSON_UTF8,
MediaType.APPLICATION_FORM_URLENCODED,
MediaType.valueOf("application/x-www-form-urlencoded;charset=UTF-8")); /**
* 加解密序列化适配器
*/
private final SecretFormatterAdapter secretFormatterAdapter; private final ModifyRequestBodyGatewayFilterFactory decryptRequestBodyFilterFactory; private final RequestBodyDecryptRewriter requestBodyDecryptRewriter; public DecryptParameterFilter(
SecretFormatterAdapter secretFormatterAdapter,
ModifyRequestBodyGatewayFilterFactory decryptRequestBodyFilterFactory,
RequestBodyDecryptRewriter requestBodyDecryptRewriter) {
this.secretFormatterAdapter = secretFormatterAdapter;
this.decryptRequestBodyFilterFactory = decryptRequestBodyFilterFactory;
this.requestBodyDecryptRewriter = requestBodyDecryptRewriter;
} @Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 读取token
String token = exchange.getRequest().getHeaders().getFirst(SystemSsoLoginStore.SSO_TOKEN); // 通过token在redis读取用户信息
SystemSsoUser user = SystemSsoLoginHelper.loginCheck(token); // 设置用户信息上下文
exchange.getAttributes().put(SystemSsoTokenFilter.GATEWAY_SSO_USER_ATTR, user);
// 设置用户上下文对象,用户加解密读取公私钥
exchange.getAttributes().put(GATEWAY_SSO_USER_KEYS_ATTR, SM2Key.build(user.getSecretKey())); // 请求类型不需要参数解密,只使用Request封装类不用重写Body
if (!ENCRYPT_MEDIA_TYPES.contains(exchange.getRequest().getHeaders().getContentType())) {
// 使用封装类为了在请求头中增加secret标记
return buildRequestDecorator(exchange, chain);
} return decryptRequestBodyFilterFactory
.apply(new ModifyRequestBodyGatewayFilterFactory.Config().setRewriteFunction(String.class, String.class, requestBodyDecryptRewriter))
.filter(new DecryptedServerWebExchangeDecorator(exchange, secretFormatterAdapter), chain);
} /**
* 构建Request封装类
* @param exchange
* @param chain
* @return
*/
protected Mono<Void> buildRequestDecorator(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange.mutate().request(new DecryptedServerHttpRequestDecorator(exchange, exchange.getRequest())).build());
} @Override
public int getOrder() {
// 需要对exchange和request对象进行封装,所以优先级放到最高
// 优先级过低可能会造成拦截器不生效
return Ordered.HIGHEST_PRECEDENCE;
} /**
* ServerHttpRequest解密封装类
* 1、处理queryString参数解密
* 2、处理body参数解密
* @author changxy
*/
static class DecryptedServerHttpRequestDecorator extends ServerHttpRequestDecorator { private ServerWebExchange originExchange; private ServerHttpRequest originRequest; public DecryptedServerHttpRequestDecorator(ServerWebExchange originExchange, ServerHttpRequest originRequest, SecretFormatterAdapter secretFormatterAdapter) {
super(originRequest);
this.originExchange = originExchange;
this.originRequest = originRequest;
this.secretFormatterAdapter = secretFormatterAdapter;
} @Override
public URI getURI() { // 获取原始请求链接
URI uri = super.getURI();
// 获取原始QueryString请求参数
MultiValueMap<String, String> originQueryParams = originRequest.getQueryParams(); // 处理QueryString请求参数解密
if (Objects.nonNull(originQueryParams) && originQueryParams.containsKey(ENCRYPT_QUERY_STRING_KEY)) {
// 获取密文
List<String> encrypted = originQueryParams.get(ENCRYPT_QUERY_STRING_KEY);
// 非空校验
if (Objects.nonNull(encrypted) && !encrypted.isEmpty()) {
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(uri); // 清空原有queryString
uriComponentsBuilder.query(null); for (String encrypt : encrypted) {
// 解密并放入queryString中
uriComponentsBuilder.query(Sm2Factory.getInstance().decrypt(originExchange, encrypt));
} // build(true) 不会再次进行URL编码
uri = uriComponentsBuilder.build(true).toUri();
return uri;
}
}
return super.getURI();
} @Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders());
// 请求微服务应用时添加加解密请求头,门户根据请求头签发证书,前端进行入参加密
headers.put("secret", Collections.singletonList(Boolean.TRUE.toString()));
return headers;
} } /**
* ServerWebExchange包装类,这里主要为了包装ServerHttpRequest
*/
static class DecryptedServerWebExchangeDecorator extends ServerWebExchangeDecorator { private final ServerHttpRequestDecorator requestDecorator; protected DecryptedServerWebExchangeDecorator(ServerWebExchange delegate, SecretFormatterAdapter secretFormatterAdapter) {
super(delegate);
this.requestDecorator = new DecryptedServerHttpRequestDecorator(delegate, delegate.getRequest(), secretFormatterAdapter);
} @Override
public ServerHttpRequest getRequest() {
return requestDecorator;
}
} }

RequestBodyDecryptRewriter

/**
* RequestBody参数重写类
* @author changxy
*/
public class RequestBodyDecryptRewriter implements RewriteFunction<String, String> { /**
* 加解密序列化适配器
*/
private final SecretFormatterAdapter secretFormatterAdapter; public RequestBodyDecryptRewriter(SecretFormatterAdapter secretFormatterAdapter) {
this.secretFormatterAdapter = secretFormatterAdapter;
} @Override
public Publisher<String> apply(ServerWebExchange exchange, String body) {
return Mono.just(decryptBody(exchange, body));
} protected String decryptBody(ServerWebExchange exchange, String body) {
if (StringUtils.hasText(body)) {
return secretFormatterAdapter.format(exchange, SecretFormatter.SecretFormatterType.DECRYPT, body);
} return body;
}
}

全局加密拦截器

实现原理和全局解密拦截器类似,这里不再赘述。

EncryptResponseFilter

public class EncryptResponseFilter implements GlobalFilter, Ordered {

    protected static final List<MediaType> MEDIA_TYPES = Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_JSON_UTF8);

    /**
* 加解密序列化适配器
*/
private final SecretFormatterAdapter secretFormatterAdapter; private final ModifyResponseBodyGatewayFilterFactory encryptFilterFactory; private final ResponseJSONEncryptRewriter jsonEncryptRewriter; private final List<String> excludedPaths; public EncryptResponseFilter(
SecretFormatterAdapter secretFormatterAdapter,
ModifyResponseBodyGatewayFilterFactory encryptFilterFactory,
ResponseJSONEncryptRewriter jsonEncryptRewriter,
List<String> excludedPaths
) {
this.secretFormatterAdapter = secretFormatterAdapter;
this.encryptFilterFactory = encryptFilterFactory;
this.jsonEncryptRewriter = jsonEncryptRewriter;
this.excludedPaths = excludedPaths;
} @Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 不处理免加密接口
if (PathMatcherFactoryInstance.match(excludedPaths, exchange.getRequest().getURI().getPath())) {
return chain.filter(exchange);
}
return chain.filter(exchange.mutate().response(new EncryptServerHttpResponseDecorator(exchange, encryptFilterFactory, jsonEncryptRewriter, chain)).build());
} @Override
public int getOrder() {
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
} /**
* ServerHttpResponse封装类
*/
static class EncryptServerHttpResponseDecorator extends ServerHttpResponseDecorator { private final ServerHttpResponse serverHttpResponse; private final ModifyResponseBodyGatewayFilterFactory encryptFilterFactory; private final ResponseJSONEncryptRewriter jsonEncryptRewriter; private final ServerWebExchange serverWebExchange; private final GatewayFilterChain chain; public EncryptServerHttpResponseDecorator(
ServerWebExchange serverWebExchange,
ModifyResponseBodyGatewayFilterFactory encryptFilterFactory,
ResponseJSONEncryptRewriter jsonEncryptRewriter,
GatewayFilterChain chain
) {
super(serverWebExchange.getResponse());
this.serverHttpResponse = serverWebExchange.getResponse();
this.serverWebExchange = serverWebExchange;
this.encryptFilterFactory = encryptFilterFactory;
this.jsonEncryptRewriter = jsonEncryptRewriter;
this.chain = chain;
} @Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
// 这里只处理返回JSON格式的响应信息
if (MEDIA_TYPES.contains(serverHttpResponse.getHeaders().getContentType()) && body instanceof Flux) {
// 通过RewriteFunction重写ResponseBody
return encryptFilterFactory.apply(new ModifyResponseBodyGatewayFilterFactory.Config().setRewriteFunction(String.class, String.class, jsonEncryptRewriter)).filter(serverWebExchange, chain);
} else {
return super.writeWith(body);
}
} @Override
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
return writeWith(Flux.from(body).flatMapSequential(publisher -> publisher));
}
} }

ResponseJSONEncryptRewriter

public class ResponseJSONEncryptRewriter implements RewriteFunction<String, String> {

    /**
* 加解密序列化适配器
*/
private final SecretFormatterAdapter secretFormatterAdapter; public ResponseJSONEncryptRewriter(SecretFormatterAdapter secretFormatterAdapter) {
this.secretFormatterAdapter = secretFormatterAdapter;
} @Override
public Publisher<String> apply(ServerWebExchange exchange, String json) {
return Mono.just(encrypt(exchange, json));
} public String encrypt(ServerWebExchange exchange, String json) {
if (StringUtils.hasText(json)) {
return secretFormatterAdapter.format(exchange, SecretFormatter.SecretFormatterType.ENCRYPT, json);
}
return json;
} }

代码中涉及到的SecretFormatterAdapter是针对不同场景实现加解密序列化适配器类,大家可以根据需求自行实现。

大家对上述代码有疑问或建议的,欢迎交流指正。

如何优雅而不损失性能的实现SpringCloud Gateway网关参数加解密方案的更多相关文章

  1. SpringCloud GateWay网关(入门)

    1.介绍 强烈推荐,看官网文档 Spring Cloud Gateway ①简介 Cloud全家桶里有个重要组件:网关 SpringCloud Gateway基于WebFlux框架 WebFlux底层 ...

  2. SpringCloud gateway (史上最全)

    疯狂创客圈 Java 分布式聊天室[ 亿级流量]实战系列之 -25[ 博客园 总入口 ] 前言 ### 前言 疯狂创客圈(笔者尼恩创建的高并发研习社群)Springcloud 高并发系列文章,将为大家 ...

  3. SpringCloud gateway 3

    参考博客:https://www.cnblogs.com/crazymakercircle/p/11704077.html 1.1 SpringCloud Gateway 简介 SpringCloud ...

  4. SpringCloud Gateway微服务网关实战与源码分析-上

    概述 定义 Spring Cloud Gateway 官网地址 https://spring.io/projects/spring-cloud-gateway/ 最新版本3.1.3 Spring Cl ...

  5. SpringCloud(7)---网关概念、Zuul项目搭建

    SpringCloud(7)---网关概念.Zuul项目搭建 一.网关概念 1.什么是路由网关 网关是系统的唯一对外的入口,介于客户端和服务器端之间的中间层,处理非业务功能 提供路由请求.鉴权.监控. ...

  6. MySQL性能优化方法一:缓存参数优化

    原文链接:http://isky000.com/database/mysql-perfornamce-tuning-cache-parameter 数据库属于 IO 密集型的应用程序,其主要职责就是数 ...

  7. SpringCloud之网关 Gateway(五)

    前面我们在聊服务网关Zuul的时候提到了Gateway,那么Zuul和Gateway都是服务网关,这两个有什么区别呢? 1. Zuul和Gateway的恩怨情仇 1.1 背景 Zuul是Netflix ...

  8. 微服务实战系列(七)-网关springcloud gateway

    1. 场景描述 springcloud刚推出的时候用的是netflix全家桶,路由用的zuul,但是据说zull1.0在大数据量访问的时候存在较大性能问题,2.0就没集成到springcloud中了, ...

  9. SpringCloud Gateway快速入门

    SpringCloud Gateway cloud笔记第一部分 cloud笔记第二部分Hystrix 文章目录 SpringCloud Gateway Zull的工作模式与Gateway的对比 Rou ...

  10. 万字长文:SpringCloud gateway入门学习&实践

    官方文档:https://cloud.spring.io/spring-cloud-static/spring-cloud-gateway/2.2.1.RELEASE/reference/html/# ...

随机推荐

  1. DASCTF 2023 & 0X401七月暑期挑战赛

    比赛只出了一道,小菜不是罪过-_- controlflow 这个题动调到底就行 for i in range(40): after_xor[i]=inp[i]^0x401 after_xor[i] + ...

  2. Oracle数据库字符集概述及修改方式

    1.字符集概述 Oracle语言环境的描述包括三部分:language.territory.characterset(语言.地域.字符集) language:主要指定服务器消息的语言,提示信息显示中文 ...

  3. 如何通过API接口获取京东的商品评论

    如果您想要获取京东的商品评论,可以通过API接口来实现.这篇文章会介绍如何使用京东API接口获取商品的评论数据. 首先,您需要到京东开放平台注册成为开发者,然后创建一个应用程序.通过这个应用程序,您可 ...

  4. K8s 多集群实践思考和探索

    作者:vivo 互联网容器团队 - Zhang Rong 本文主要讲述了一些对于K8s多集群管理的思考,包括为什么需要多集群.多集群的优势以及现有的一些基于Kubernetes衍生出的多集群管理架构实 ...

  5. IOS 16 无法打开开发版或者企业版本APP解决方案 - 需要开启开发者模式

    在IOS 16系统上,打开开发版本APP,或者企业版本APP时,会看到如下的提示信息: 需要开启开发者模式, xxx 需要在开发者模式下运行. 启用开发者模式前, 此App不可用 这个时由于IOS 1 ...

  6. FreeSWITCH容器化问题之rtp端口占用

    操作系统 :CentOS 7.6_x64.debian 11 (bullseye,docker) FreeSWITCH版本 :1.10.9 Docker版本:23.0.6 FreeSWITCH容器化带 ...

  7. 记一次 .NET某新能源MES系统 非托管泄露

    一:背景 1. 讲故事 前些天有位朋友找到我,说他们的程序有内存泄露,跟着我的错题集也没找出是什么原因,刚好手头上有一个 7G+ 的 dump,让我帮忙看下是怎么回事,既然找到我了那就给他看看吧,不过 ...

  8. 解决Dependency 'fastdfs-client-java’not found

    如何能把 fastdfs的jar包安装到本地的仓库中(因为中央仓库没有FASTDFS的jar包地址) 1.首先去github上下载下来fastdfs的压缩包 下载链接 然后直接解压出来 2.使用cmd ...

  9. ElasticSearch系列——查询、Python使用、Django/Flask集成、集群搭建,数据分片、位置坐标实现附近的人搜索

    @ 目录 Elasticsearch之-查询 一 基本查询 1.1 match查询 1.2 term查询 1.3 terms查询 1.4 控制查询的返回数量(分页) 1.5 match_all 查询 ...

  10. Flask框架——flask介绍

    文章目录 1 什么是flask? 2 为什么要有flask? 3 学前准备:虚拟环境 3.1 虚拟环境是什么? 3.2 如何使用虚拟环境? 3.2.1 搭建虚拟环境 3.2.1 在虚拟环境中安装我们的 ...