点击上方蓝色“冰河技术”,关注并选择“设为星标”
持之以恒,贵在坚持,每天进步一点点!
作者个人研发的在高并发场景下,提供的简单、稳定、可扩展的延迟消息队列框架,具有精准的定时任务和延迟队列处理功能。自开源半年多以来,已成功为十几家中小型企业提供了精准定时调度方案,经受住了生产环境的考验。为使更多童鞋受益,现给出开源框架地址:
https://github.com/sunshinelyz/mykit-delay
PS: 欢迎各位Star源码,也可以pr你牛逼哄哄的代码。
写在前面
不少小伙伴让我整理下有关SpringCloud和SpringCloudAlibaba的知识点,经过3天的收集和整理,冰河整理出这份4万字的SpringCloud与SpringCloudAlibaba学习笔记!!
文章已收录到:
https://github.com/sunshinelyz/technology-binghe
https://gitee.com/binghe001/technology-binghe
SpringCloud
服务注册中心
eureka
ap 高可用 分布式容错
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
eureka:
instance:
hostname: eureka7003.com #eureka服务端的实例名称
instance-id: payment8001
prefer-ip-address: true
client:
register-with-eureka: false #false表示不向注册中心注册自己。
fetch-registry: false #false表示自己端就是注册中心,我的职责就是维护服务实例,并不需要去检索服务
service-url:
#集群指向其它eureka
#defaultZone: http://eureka7002.com:7002/eureka/
#单机就是7001自己
defaultZone: http://eureka7001.com:7001/eureka/,http://eureka7002.com:7002/eureka/
#server:
#关闭自我保护机制,保证不可用服务被及时踢除
#enable-self-preservation: false
#eviction-interval-timer-in-ms: 2000
Ribbon 启用负载均衡
@EnableEurekaServer
@EnableDiscoveryClient
@LoadBalanced
public RestTemplate getTemp() {
return new RestTemplate();
}
zookepper
cp 强一致 分布式容错
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
<exclusions>
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.6.1</version>
</dependency>
spring:
application:
name: cloud-zoo-consumer-order
cloud:
zookeeper:
connect-string: 192.168.10.58:2181
@SpringBootApplication
@EnableDiscoveryClient
consul
cp 强一致 分布式容错
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
spring:
application:
name: consul-payment-provider
cloud:
consul:
host: 192.168.10.58
port: 8500
discovery:
service-name: ${spring.application.name}
@SpringBootApplication
@EnableDiscoveryClient
服务调用负载均衡
Ribbon
Ribbon 切换 负载规则
-
在springboot 包扫描外层建立 配置
@Configuration
public class Myrule {
@Bean
public IRule initRule() {
return new RandomRule();
}
}
-
启动类给指定服务加载随机方法
@RibbonClient(name = "CLOUD-PAYMENT-SERVICE", configuration = Myrule.class)
OpenFeign
-
添加maven依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
-
启动类启用Feign
@SpringBootApplication
@EnableEurekaClient
@EnableDiscoveryClient
@EnableFeignClients
-
新建接口 并注册Feign信息
@Component
@FeignClient(value = "CLOUD-PAYMENT-SERVICE") //提供方服务名
public interface Service {
@GetMapping(value = "/payment/get/{id}")
Response<Payment> getPaymentById(@PathVariable("id") Long id);
}
-
提供方接口演示
@GetMapping(value = "/payment/get/{id}")
public Response<Payment> getPaymentById(@PathVariable("id") Long id) {
Payment payment = paymentService.getPaymentById(id);
if (payment != null) {
return Result.success(200, "查询成功,serverPort: " + serverPort, payment);
} else {
return Result.success(444, "没有对应记录,查询ID: " + id, null);
}
}
OpenFeign超时设置
ribbon:
#指的是建立连接所用的时间,适用于网络状况正常的情况下,两端连接所用的时间
ReadTimeout: 5000
#指的是建立连接后从服务器读取到可用资源所用的时间
ConnectTimeout: 5000
OpenFeign 日志打印功能
-
配置Openfeign 日志级别
@Configuration
public class FeignLogConfig {
@Bean
public Logger.Level getLevel() {
return Logger.Level.FULL;
}
}
-
yml 项目配置文件中,给指定Feign interface 配置日志级别
logging:
level:
ml.ytooo.feignservice.Service: debug
Hystrix 服务治理
-
服务降级 出险异常时,返回友好提示,防止程序异常或者阻塞 -
服务熔断 保险丝,当超出服务承载能力时,返回提示,拒绝请求 -
服务限流 闸门,配置服务承载能力
Hystrix
服务降级
当服务处理超时或者运行异常时,启动备选方案返回给调用者预期结果
主方法
@EnableCircuitBreaker
需要降级处理的程序
其中
-
paymentInfo_TimeOut 为预计超时程序 -
paymentInfo_TimeOut_Handler 为超时备选方案
@HystrixCommand(fallbackMethod = "paymentInfo_TimeOut_Handler", commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "3000")
})
public String paymentInfo_TimeOut(Integer id) {
int time = 5;
try { TimeUnit.MILLISECONDS.sleep(time * 1000); } catch (InterruptedException e) { e.printStackTrace(); }
return "线程池: " + Thread.currentThread().getName() + " paymentInfo_TimeOut,id: " + id + "\t" + "O(∩_∩)O哈哈~" + " 耗时(秒): " + time;
}
public String paymentInfo_TimeOut_Handler(Integer id) {
return "线程池: " + Thread.currentThread().getName() + " paymentInfo_TimeOut_Handler,id: " + id + "\t" + "o(╥﹏╥)o";
}
全局降级处理
配置 defaultFallback 的走自己的降级方法,未配置的走 默认@DefaultProperties 指定的降级方法
@RestController
@Slf4j
@DefaultProperties(defaultFallback = "globle",commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1500")
})
public class Controller {
@HystrixCommand
@GetMapping("/timeout/{id}")
public String paymentInfo_TimeOut(@PathVariable("id") Integer id) {
String result = feignService.paymentInfo_TimeOut(id);
log.info("*****result: " + result);
return result;
}
public String globle() {
return "全局";
}
}
通过OpenFeign 配置其提供方全局降级配置
-
新增feign调用接口的实现类 FeignServiceImpl,实现全部方法并做降级处理
@Service
public class FeignServiceImpl implements FeignService {
@Override
public String paymentInfo_OK(Integer id) {
return "降级 -- paymentInfo_OK";
}
@Override
public String paymentInfo_TimeOut(Integer id) {
return "降级 -- paymentInfo_TimeOut";
}
}
-
feign调用接口添加注解
@FeignClient(value = "CLOUD-PROVIDER-HYSTYRIX-PAYMENT",fallback = FeignServiceImpl.class)
服务熔断
-
服务过载时,拒绝连接请求直接调用降级方法,返回异常 -
请求下降时,慢慢恢复该服务访问,直达完全恢复
配置服务的熔断:
一下配置在 10s 内 ,10次请求有60% 失败,则熔断
HystrixProperty 配置位于 HystrixCommandProperties.class 类中
//=====服务熔断
@HystrixCommand(fallbackMethod = "paymentCircuitBreaker_fallback",commandProperties = { //
@HystrixProperty(name = "circuitBreaker.enabled",value = "true"),// 是否开启断路器
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold",value = "10"),// 请求次数
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds",value = "10000"), // 时间窗口期
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage",value = "60"),// 失败率达到多少百分百后跳闸
})
public String paymentCircuitBreaker(@PathVariable("id") Integer id)
{
if(id < 0)
{
throw new RuntimeException("******id 不能负数");
}
String serialNumber = IdUtil.simpleUUID();
return Thread.currentThread().getName()+"\t"+"调用成功,流水号: " + serialNumber;
}
public String paymentCircuitBreaker_fallback(@PathVariable("id") Integer id) //熔断后降级方法
{
return "id 不能负数,请稍后再试,/(ㄒoㄒ)/~~ id: " +id;
}
效果: 当连续使用 -100 请求时, 返回 "id 不能负数", 使用100请求也返回 "id 不能负数" ,继续连续使用 100请求, 服务慢慢恢复
服务限流
使用 springcloud 阿里巴巴 sentinel 替代
Gateway 网关
Gateway
Gateway 项目配置
-
添加maven依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
-
移除以下依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
-
yml 配置 (后续移步配置中心)
spring:
application:
name: cloud-gateaway-gateaway
cloud:
gateway:
discovery:
locator:
enabled: true
routes:
- id: payment_get
# uri: http://127.0.0.1:8001 #单一节点
uri : lb://CLOUD-PAYMENT-SERVICE /#启用 注册中心集群
predicates:
- Path=/payment/get/**
-
注册进 Eureka 注册中心
Gateway 动态路由
-
开启 网关发现注册中心服务
spring:
application:
name: cloud-gateaway-gateaway
cloud:
gateway:
discovery:
locator:
enabled: true
Gateway 断言
断言是判断转发请求的条件
predicates:
- After=2020-02-21T15:51:37.485+08:00[Asia/Shanghai]
-
After,Before,Between 配置转发生效时间
public static void main(String[] args) {
ZonedDateTime now = ZonedDateTime.now();
System.out.println(now);
}
// 2020-08-24T14:23:57.171+08:00[Asia/Shanghai]
-
Cookie 请求需携带指定Cookie才可以访问
predicates:
- Cookie=name,ytooo # key,value
-
Header ≈ Cookie
predicates:
- Header=name,ytooo # key,value
Gateway 过滤器
-
(默认过滤器) 官方提供一系列过滤器,供我们 在请求转发之前,对请求进行加工处理
filters:
- AddRequestParamter=rowid,1024
-
自定义过滤器
自定义全局过滤器
@Component
@Slf4j
public class GatewayLogFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("=====================进入全局过滤器=====================");
String name = exchange.getRequest().getQueryParams().getFirst("name");
if (null == name) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
SpringCloud Config 分布式配置中心, BUS 消息总线
分布式配置中心 SpringCloud Config
服务端配置
-
建立git仓库 https://github.com/sunshinelyz/cloud-config -
引入 maven
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
3.启动类使配置生效
@SpringBootApplication
@EnableEurekaClient
@EnableConfigServer
public class ConfigMain3344 {
public static void main(String[] args) {
SpringApplication.run(ConfigMain3344.class, args);
}
}
-
配置 配置文件
spring:
application:
name: cloud-config-center
cloud:
config:
server:
git:
uri: https://github.com/sunshinelyz/cloud-config
search-paths:
- cloud-config
label: master
-
请求访问 : http://127.0.0.1:3344/master/config-dev.yml
客户端配置
-
引入 maven
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-client</artifactId>
</dependency>
-
配置 配置文件
spring:
application:
name: cloud-condig-client
cloud:
config:
label: master # 分支
name: config # 配置文件名称
profile: dev # 版本
uri: http://127.0.0.1:3344 # config服务端地址
手动刷新客户端配置
不建议使用
消息总线 Bus
设计逻辑
使用消息总线触发服务端的 bus/refresh 端点,刷新所有客户端config配置
初始条件
客户端,服务端都需要实现Springcloud Config功能
服务端配置
-
引入maven依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
-
配置文件中配置消息队列信息
# 配置消息队列
rabbitmq:
host: 192.168.10.58
port: 5672
username: ytooo
password: ytooo
-
配置文件中配置BUS总线暴露信息
# 配置bus暴露端点
management:
endpoints:
web:
exposure:
include: "bus-refresh"
-
配置文件预览
server:
port: 3344
spring:
application:
name: cloud-config-center
cloud:
config:
server:
git:
uri: https://github.com/sunshinelyz/cloud-config
search-paths:
- cloud-config
label: master
# 配置消息队列
rabbitmq:
host: 192.168.10.58
port: 5672
username: ytooo
password: ytooo
eureka:
instance:
prefer-ip-address: true
instance-id: cloud-config-center-3344
client:
fetch-registry: true
register-with-eureka: true
service-url:
defaultZone: http://eureka7001.com:7001/eureka/ #,http://eureka7002.com:7002/eureka/,http://eureka7003.com:7003/eureka/
# 配置bus暴露端点
management:
endpoints:
web:
exposure:
include: "bus-refresh"
客户端配置
-
引入maven依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
-
配置文件中配置消息队列信息
# 配置消息队列
rabbitmq:
host: 192.168.10.58
port: 5672
username: ytooo
password: ytooo
-
配置暴露端点
# 配置暴露端点
management:
endpoints:
web:
exposure:
include: "*"
-
调用配置类添加 @RefreshScope
@RestController
@RefreshScope
public class Controller {
@Value("${config.info}")
private String configInfo;
@GetMapping(value = "/test")
public String test() {
return configInfo;
}
}
刷新配置
POST 请求config服务端 http://127.0.0.1:3344/actuator/bus-refresh
刷新成功
定点通知
POST 请求config服务端 http://127.0.0.1:3344/actuator/bus-refresh/{destination}
destination: 注册中心服务名称:端口号
🌰: http://127.0.0.1:3344/actuator/bus-refresh/cloud-condig-client:3366
SpringCloud Stream 消息驱动
消息驱动,统一各种消息中间件中的差异,提供统一简单的调用方式,屏蔽消息中间件具体调用实现
SpringCloud Stream 消息提供者项目配置 (简单使用)
-
添加maven依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
-
配置文件添加Stream配置
spring:
application:
name: cloud-stream-provider
cloud:
stream:
binders: # 在此处配置要绑定的rabbitmq的服务信息;
defaultRabbit: # 表示定义的名称,用于于binding整合
type: rabbit # 消息组件类型
environment: # 设置rabbitmq的相关的环境配置
spring:
rabbitmq:
host: 192.168.10.58
port: 5672
username: ytooo
password: ytooo
bindings: # 服务的整合处理
output: # 这个名字是一个通道的名称
destination: studyExchange # 表示要使用的Exchange名称定义
content-type: application/json # 设置消息类型,本次为json,文本则设置“text/plain”
binder: defaultRabbit # 设置要绑定的消息服务的具体设置
-
定义消息推送管道
@EnableBinding(Source.class) //定义消息推送管道
public class MsgProviderImpl implements MsgProvider {
}
-
定义消息发送管道
@Autowired
private MessageChannel out; //定义消息发送管道
-
build消息实体并发送
Message<String> message = MessageBuilder.withPayload(msg).build();
out.send(message);
-
消息接收方 yml 配置
spring:
application:
name: cloud-stream-rabbitmq-consumer
cloud:
stream:
binders: # 在此处配置要绑定的rabbitmq的服务信息;
defaultRabbit: # 表示定义的名称,用于于binding整合
type: rabbit # 消息组件类型
environment: # 设置rabbitmq的相关的环境配置
spring:
rabbitmq:
host: 192.168.10.58
port: 5672
username: ytooo
password: ytooo
bindings: # 服务的整合处理
input: # 这个名字是一个通道的名称
destination: studyExchange # 表示要使用的Exchange名称定义 需与提供方相同
content-type: application/json # 设置消息类型,本次为json,文本则设置“text/plain”
binder: defaultRabbit # 设置要绑定的消息服务的具体设置
-
接收方监听接口配置
@EnableBinding(Sink.class)
public class ReceiveMsgImpl implements ReceiveMsg {
}
-
接收方注解
@StreamListener(Sink.INPUT)
public void receive(Message<String> message) {
System.out.println("客服端8803收到消息: " + message.getPayload());
}
SpringCloud Stream 分组消费 & 持久化
-
对于不同的组中,消息是会被重复消费的(重复消费) -
同一个组内,服务之间存在竞争关系,只被消费一次(集群环境,避免重复消费)
分组配置文件配置
bindings: # 服务的整合处理
input: # 这个名字是一个通道的名称
destination: studyExchange # 表示要使用的Exchange名称定义
content-type: application/json # 设置消息类型,本次为json,文本则设置“text/plain”
binder: defaultRabbit # 设置要绑定的消息服务的具体设置
group: ytooo # 配置接收方所在组
group分组 持久化
当不配置分组时,重启服务,不会自动获取之前未消费的服务 反之,配置了分组,启动时,自动获取之前未消费的消息
Sleuth 分布式请求链路跟踪
在服务中加上依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
修改application.yml配置文件,添加以下内容
spring:
zipkin:
base-url: http://localhost:9411
sleuth:
sampler:
probability: 1 # 采样率值介于0~1之间,1表示全部采集
SpringCloud Alibaba
Nacos 注册中心
添加依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
项目配置文件
spring:
application:
name: cloudalibaba-nacos-consumer-order
cloud:
nacos:
discovery:
server-addr: 192.168.10.58:8848
主程序启动
@EnableDiscoveryClient
结合 Openfeign 调用 服务
Nacos config配置中心
-
添加maven 依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-nacos-config</artifactId>
</dependency>
-
bootstrap.yml中添加 配置中心相关配置
spring:
application:
name: cloudalibaba-nacos-config-cclient
cloud:
nacos:
discovery:
server-addr: 192.168.10.58:8848
config:
server-addr: 192.168.10.58:8848
file-extension: yaml
profiles:
active: dev
-
业务类 添加 @RefreshScope 来自动刷新
@RestController
@RefreshScope
public class Controller {
-
配置文件中配置格式
在 Nacos Spring Cloud 中,dataId 的完整格式如下:
prefix−{prefix}-prefix−{spring.profiles.active}.${file-extension}
-
prefix 默认为 spring.application.name 的值,也可以通过配置项 spring.cloud.nacos.config.prefix来配置。 -
spring.profiles.active 即为当前环境对应的 profile,详情可以参考 Spring Boot文档。注意:当 spring.profiles.active 为空时,对应的连接符 - 也将不存在,dataId 的拼接格式变成 prefix.{prefix}.prefix.{file-extension} -
file-exetension 为配置内容的数据格式,可以通过配置项 spring.cloud.nacos.config.file-extension 来配置。目前只支持 properties 和 yaml 类型。
本实例中文件名称应为: cloudalibaba-nacos-config-cclient-dev.yaml
Nacos config配置中心 分类配置
Nacos 使用3层来隔离服务
-
NameSpace 命名空间,默认 public -
Group 组, 默认 DEFAULT_GROUP -
Service 微服务, 一个Service 可以包含多个Cluster集群,默认Cluster为 DEFAULT 可以用了区分地域,来做地域容灾
yml 中配置分组信息group ,和命名空间 namespace
spring:
application:
name: cloudalibaba-nacos-config-cclient
cloud:
nacos:
discovery:
server-addr: 192.168.10.58:8848
config:
server-addr: 192.168.10.58:8848
file-extension: yaml
group: dev
namespace: a2438b02-01e1-4a3c-959c-600d93183d22 # 使用命名空间ID
Sentinel 服务熔断与降级
-
单独的组件 -
可以界面化,细粒度统一配置
Sentinel 服务被管方 配置
-
maven 依赖
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId> <!-- Sentinel持久化 -->
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
-
yml 中配置 sentinel dashboard 监控
spring:
application:
name: cloudalibaba-sentinel-service
cloud:
nacos:
discovery:
server-addr: 192.168.10.58:8848
sentinel:
transport:
dashboard: 192.168.10.58:8888 # sentinel dashboard 地址
配置降级方法: 此方法只针对页面中配置的指定 降级限流热点等方法
@GetMapping(value = "/hot")
@SentinelResource(value = "hot", blockHandler = "deal_hot")
public String hot(String p1, String p2) {
return "========================== 热点通过 ==========================";
}
public String deal_hot(String p1, String p2, BlockException e) {
return "========================== 热点降级 ==========================";
}
seata 分布式事务
-
全局事务id -
TC 事务协调者 维护全局和分支事务状态,驱动全局事务的提交或回滚 -
TM 事务管理器 定义全局事务的范围,开始全局事务.提交或回滚全局事务 -
RM 资源管理器 管理分支事务处理的资源,与TC交谈来注册分支事务和报告分支事务的状态,并驱动分支事务的提交或回滚
好了,今天就到这儿吧,我是冰河,大家有啥问题可以在下方留言,也可以加我微信:sun_shine_lyz,我拉你进群,一起交流技术,一起进阶,一起牛逼~~
冰河原创PDF
关注 冰河技术 微信公众号:
回复 “并发编程” 领取《深入理解高并发编程(第1版)》PDF文档。
回复 “并发源码” 领取《并发编程核心知识(源码分析篇 第1版)》PDF文档。
回复 ”限流“ 领取《亿级流量下的分布式解决方案》PDF文档。
回复 “设计模式” 领取《深入浅出Java23种设计模式》PDF文档。
回复 “Java8新特性” 领取 《Java8新特性教程》PDF文档。
回复 “分布式存储” 领取《跟冰河学习分布式存储技术》 PDF文档。
回复 “Nginx” 领取《跟冰河学习Nginx技术》PDF文档。
回复 “互联网工程” 领取《跟冰河学习互联网工程技术》PDF文档。
写在最后
如果你觉得冰河写的还不错,请微信搜索并关注「 冰河技术 」微信公众号,跟冰河学习高并发、分布式、微服务、大数据、互联网和云原生技术,「 冰河技术 」微信公众号更新了大量技术专题,每一篇技术文章干货满满!不少读者已经通过阅读「 冰河技术 」微信公众号文章,成功跳槽到大厂;也有不少读者实现了技术上的飞跃,成为公司的技术骨干!如果你也想像他们一样提升自己的能力,实现技术能力的飞跃,进大厂,升职加薪,那就关注「 冰河技术 」微信公众号吧,每天更新超硬核技术干货,让你对如何提升技术能力不再迷茫!
本文分享自微信公众号 - 冰河技术(hacker-binghe)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
来源:oschina
链接:https://my.oschina.net/bingheteam/blog/4952143