如何优雅而不损失性能的实现SpringCloud Gateway网关参数加解密方案
背景
为了增强产品安全性,计划对应用网关进行改造,主要是出入参经过网关时需要进行加解密操作,保证请求数据在网络传输过程中不会泄露或篡改。
考虑到密钥的安全性,每个用户登录都会签发独立的密钥对。同时摒弃了对称加密算法,使用国密非对称的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网关参数加解密方案的更多相关文章
- SpringCloud GateWay网关(入门)
1.介绍 强烈推荐,看官网文档 Spring Cloud Gateway ①简介 Cloud全家桶里有个重要组件:网关 SpringCloud Gateway基于WebFlux框架 WebFlux底层 ...
- SpringCloud gateway (史上最全)
疯狂创客圈 Java 分布式聊天室[ 亿级流量]实战系列之 -25[ 博客园 总入口 ] 前言 ### 前言 疯狂创客圈(笔者尼恩创建的高并发研习社群)Springcloud 高并发系列文章,将为大家 ...
- SpringCloud gateway 3
参考博客:https://www.cnblogs.com/crazymakercircle/p/11704077.html 1.1 SpringCloud Gateway 简介 SpringCloud ...
- SpringCloud Gateway微服务网关实战与源码分析-上
概述 定义 Spring Cloud Gateway 官网地址 https://spring.io/projects/spring-cloud-gateway/ 最新版本3.1.3 Spring Cl ...
- SpringCloud(7)---网关概念、Zuul项目搭建
SpringCloud(7)---网关概念.Zuul项目搭建 一.网关概念 1.什么是路由网关 网关是系统的唯一对外的入口,介于客户端和服务器端之间的中间层,处理非业务功能 提供路由请求.鉴权.监控. ...
- MySQL性能优化方法一:缓存参数优化
原文链接:http://isky000.com/database/mysql-perfornamce-tuning-cache-parameter 数据库属于 IO 密集型的应用程序,其主要职责就是数 ...
- SpringCloud之网关 Gateway(五)
前面我们在聊服务网关Zuul的时候提到了Gateway,那么Zuul和Gateway都是服务网关,这两个有什么区别呢? 1. Zuul和Gateway的恩怨情仇 1.1 背景 Zuul是Netflix ...
- 微服务实战系列(七)-网关springcloud gateway
1. 场景描述 springcloud刚推出的时候用的是netflix全家桶,路由用的zuul,但是据说zull1.0在大数据量访问的时候存在较大性能问题,2.0就没集成到springcloud中了, ...
- SpringCloud Gateway快速入门
SpringCloud Gateway cloud笔记第一部分 cloud笔记第二部分Hystrix 文章目录 SpringCloud Gateway Zull的工作模式与Gateway的对比 Rou ...
- 万字长文:SpringCloud gateway入门学习&实践
官方文档:https://cloud.spring.io/spring-cloud-static/spring-cloud-gateway/2.2.1.RELEASE/reference/html/# ...
随机推荐
- 《Kali渗透基础》11. 无线渗透(一)
@ 目录 1:无线技术 2:IEEE 802.11 标准 2.1:无线网络分层 2.2:IEEE 2.3:日常使用标准 2.3.1:802.11 2.3.2:802.11b 2.3.3:802.11a ...
- python 面试题第一弹
1. 如何理解Python中的深浅拷贝 浅拷贝(Shallow Copy)创建一个新的对象,该对象的内容是原始对象的引用.这意味着新对象与原始对象共享相同的内存地址,因此对于可变对象来说,如果修改了其 ...
- 如何使用Grid中的repeat函数
在本文中,我们将探索 CSS Grid repeat() 函数的所有可能性,它允许我们高效地创建 Grid 列和行的模式,甚至无需媒体查询就可以创建响应式布局. 不要重复自己 通过 grid-temp ...
- 使用HTML一键打包IPA工具打包KRPANO全景项目
该软件已经被GDB苹果网页一键打包工具取代,详情参考如下链接 GDB苹果网页一键打包 HTML一键打包IPA(苹果应用)工具可以把本地HTML项目或者网站打包为一个苹果应用IPA文件,无需编写任何代码 ...
- Linux升级至glibc-2.14步骤
Linux升级至glibc-2.14步骤 查看gcc版本命令: strings /lib64/libc.so.6 |grep GLIBC_ glibc安装 首先, 点击此处下载glibc2.14下载, ...
- 入门篇-其之四-字符串String的简单使用
什么是字符串? 在Java编程语言中,字符串用于表示文本数据. 字符串(String)属于引用数据类型,根据String的源码,其头部使用class进行修饰,属于类,即引用数据类型. 字符串的表示 字 ...
- DevOps|研发效能团队组织架构和能力建设
研发效能团队相对于各个公司主营业务规模来说并不是很大,但是在经历的几家公司里主要是有两种组织架构,职能独立型组织架构和业务闭环型组织架构.本文主要讲解这两种组织架构的特点.优劣.劣势. 业务闭环组织架 ...
- Java四种引用 强引用,软引用,弱引用,虚引用(转)
强引用 : 只要引用存在,垃圾回收器永远不会回收 Object obj= new Object(); Object 对象对后面 new Object的一个强引用, 只有当obj这个被释放之后,对象才会 ...
- 分布式与微服务——Iaas,Paas和Saas、单体应用和缺点、微服务概念、传统 分布式 SOA 架构与微服务架构的区别、微服务实战、什么是RPC、CAP定理和BASE理论、唯一ID生成、实现分布式
文章目录 1-什么是Iaas,Paas和Saas 一 IaaS基础设施服务 二 paas平台即服务 三saas软件即服务 四 总结 2-单体应用和缺点 一 单体应用 二 单体应用的缺陷 3-微服务概念 ...
- 背景图片随机API
在美化博客园的时候,遇到了一个问题:博客背景图片只支持一张图片,看到有道友说可以用API随机图片. 于是就有了这篇文章. 本文主要整理了一些随机图片API,希望对你有帮助. 岁月小筑 https:// ...