背景

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

考虑到密钥的安全性,每个用户登录都会签发独立的密钥对。同时摒弃了对称加密算法,使用国密非对称的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. Python生成30万条Excel 测试数据

    使用Python生成30万条Excel 测试数据 from openpyxl import Workbook from concurrent.futures import ThreadPoolExec ...

  2. 震惊!CSS 也能实现碰撞检测?

    本文,我们将一起学习,使用纯 CSS,实现如下所示的动画效果: 上面的动画效果,非常有意思,核心有两点: 小球随机做 X.Y 方向的直线运动,并且能够实现碰撞到边界的时候,实现反弹效果 小球在碰撞边界 ...

  3. 番外1.ssh连接管理器

    目录 本篇前瞻 项目背景 ssh连接管理器 优点 使用方式 配置 使用方法 快速开始 注意点 使用样例 本篇后记 本篇前瞻 学习完go语言基础的专栏,我们究竟写出怎么样的实用工具呢?我在github上 ...

  4. 「にちじょう記録」MTIDnWtMOA

    Mistakes That I Don't Want to Make Once Again. // Caution // 差分 / 前缀和后注意询问区间端点有变化-- 不要考虑了右边界就不考虑左边界 ...

  5. Note -「Suffix Automaton」SAM

    Part. 1 基本信息 Part. 1-1 SAM 的构成. SAM 由两个东西构成,一个是一个 DAWG,还有一棵外向树,叫 parent tree. 比如,给你一个字符串 \(S=\sf abb ...

  6. 【算法】国庆加班,火锅与Linq.AddRange的奇妙螺旋

    在国庆假期的一个傍晚,小悦正在家中享受火锅美食.她嘴里咀嚼着鲜嫩的牛肉,脸上洋溢着满足的微笑.突然,手机铃声响起,打破了这温馨的氛围.她拿起手机一看,是公司打来的电话. "小悦,有个紧急的项 ...

  7. 递归解析Json,实现生成可视化Tree+快速获取JsonPath

    内部平台的一个小功能点的实现过程,分享给大家: 递归解析Json,可以实现生成可视化Tree+快速获取JsonPath. 步骤: 1.利用JsonPath读取根,获取JsonObject 2.递归层次 ...

  8. 教学法学期末考试MOOC01

    期末考试 返回 期末考试试卷为客观题,总分为100分,占课程成绩的40%.其中包含16道单选题,2道多选题.共18道题.单选题每道5分,多选题每道10分,限时90分钟完成.  倒计时: 01:13:4 ...

  9. MySQL8.0默认加密连接方式

    Mysql8.0开始默认采用新的caching_sha2_password的身份验证方式,常规老接口会因此无法连接数据库. 为继续使用老的身份验证方式,需显式指定身份验证方式为 mysql_nativ ...

  10. Python 模块:创建、导入和使用

    什么是模块? 将模块视为代码库.模块是一个包含一组函数的文件,您想要在应用程序中包含这些函数. 创建一个模块 要创建一个模块,只需将要包含在其中的代码保存在扩展名为 .py 的文件中: 示例:将以下代 ...