Flink算子大全:Transformation操作符完全指南
前言
上一篇我们学习了 Flink 的 Source,知道了数据怎么进来。这篇文章来聊聊 Transformation(转换算子)------数据进来之后怎么加工处理。
算子是 Flink 程序的核心,就像工厂里的流水线工位。数据流过每个算子,被加工、过滤、聚合,最终变成我们想要的结果。掌握常用算子,是写好 Flink 程序的基础。
🏠个人主页:你的主页
目录
一、算子分类总览
1.1 算子全景图
┌────────────────────────────────────────────────────────────────────────┐
│ Flink Transformation 算子分类 │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 基础转换(一进一出) │ │
│ │ map flatMap filter project │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 分区操作(重分布数据) │ │
│ │ keyBy shuffle rebalance rescale broadcast │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 聚合操作(需要先keyBy) │ │
│ │ reduce sum min max minBy maxBy aggregate │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 多流操作(合并或连接) │ │
│ │ union connect coMap coFlatMap join coGroup │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 窗口操作(后续专门讲) │ │
│ │ window timeWindow countWindow trigger evictor │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
1.2 算子的输入输出关系
| 算子类型 | 输入 | 输出 | 说明 |
|---|---|---|---|
| map | 1条 | 1条 | 一对一转换 |
| flatMap | 1条 | 0~N条 | 一对多转换 |
| filter | 1条 | 0或1条 | 过滤 |
| keyBy | DataStream | KeyedStream | 按key分组 |
| reduce | KeyedStream | DataStream | 归约聚合 |
| union | 多个DataStream | 1个DataStream | 合并同类型流 |
| connect | 2个DataStream | ConnectedStream | 连接不同类型流 |
二、基础转换算子
2.1 map:一对一转换
作用:对每条数据进行转换,输入一条输出一条。
java
// 示例1:简单类型转换
DataStream<Integer> numbers = env.fromElements(1, 2, 3, 4, 5);
DataStream<Integer> doubled = numbers.map(n -> n * 2);
// 输入:1, 2, 3, 4, 5
// 输出:2, 4, 6, 8, 10
// 示例2:类型变换
DataStream<String> strings = numbers.map(n -> "Number: " + n);
// 输入:1, 2, 3
// 输出:"Number: 1", "Number: 2", "Number: 3"
// 示例3:对象转换
DataStream<Order> orders = ...;
DataStream<OrderDTO> dtos = orders.map(order -> {
OrderDTO dto = new OrderDTO();
dto.setOrderId(order.getId());
dto.setAmount(order.getPrice() * order.getQuantity());
return dto;
});
map 算子:
输入流: ──●────●────●────●────●──→
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
f(●) f(●) f(●) f(●) f(●)
│ │ │ │ │
↓ ↓ ↓ ↓ ↓
输出流: ──○────○────○────○────○──→
每个输入对应一个输出
2.2 flatMap:一对多转换
作用:一条输入可以产生0条、1条或多条输出。
java
// 示例1:切分单词(经典用法)
DataStream<String> lines = env.fromElements("hello world", "flink is great");
DataStream<String> words = lines.flatMap((String line, Collector<String> out) -> {
for (String word : line.split(" ")) {
out.collect(word);
}
});
// 输入:"hello world", "flink is great"
// 输出:"hello", "world", "flink", "is", "great"
// 示例2:过滤+转换(返回0或1条)
DataStream<Integer> numbers = env.fromElements(1, 2, 3, 4, 5);
DataStream<Integer> evenDoubled = numbers.flatMap((Integer n, Collector<Integer> out) -> {
if (n % 2 == 0) {
out.collect(n * 2);
}
// 奇数不输出任何数据
});
// 输入:1, 2, 3, 4, 5
// 输出:4, 8
// 示例3:JSON数组展开
DataStream<String> jsonArrays = env.fromElements("[1,2,3]", "[4,5]");
DataStream<Integer> flattened = jsonArrays.flatMap((String json, Collector<Integer> out) -> {
// 解析JSON数组,每个元素单独输出
ObjectMapper mapper = new ObjectMapper();
int[] array = mapper.readValue(json, int[].class);
for (int value : array) {
out.collect(value);
}
});
// 输入:"[1,2,3]", "[4,5]"
// 输出:1, 2, 3, 4, 5
flatMap 算子:
输入流: ──●─────────●──────────●──→
│ │ │
↓ ↓ ↓
f(●) f(●) f(●)
/|\ | (无输出)
/ | \ |
↓ ↓ ↓ ↓
输出流: ──○──○──○────○────────────→
一个输入可产生多个输出,也可能无输出
2.3 filter:过滤
作用:根据条件过滤数据,只保留满足条件的。
java
// 示例1:简单过滤
DataStream<Integer> numbers = env.fromElements(1, 2, 3, 4, 5);
DataStream<Integer> evenNumbers = numbers.filter(n -> n % 2 == 0);
// 输入:1, 2, 3, 4, 5
// 输出:2, 4
// 示例2:对象过滤
DataStream<Order> orders = ...;
DataStream<Order> bigOrders = orders.filter(order -> order.getAmount() > 1000);
// 只保留金额大于1000的订单
// 示例3:复杂条件
DataStream<User> users = ...;
DataStream<User> activeVipUsers = users.filter(user ->
user.isVip() &&
user.getLastLoginTime() > System.currentTimeMillis() - 86400000
);
// VIP且最近24小时登录过的用户
2.4 map vs flatMap vs filter 对比
┌────────────────────────────────────────────────────────────────────┐
│ 三个算子对比 │
│ │
│ map: 输入1条 → 输出1条(必须输出) │
│ 适合:类型转换、字段提取、计算 │
│ │
│ flatMap: 输入1条 → 输出0~N条(灵活) │
│ 适合:切分、展开、过滤+转换 │
│ │
│ filter: 输入1条 → 输出0或1条(原样保留或丢弃) │
│ 适合:单纯的条件过滤 │
│ │
│ 选择建议: │
│ - 只是转换格式? → map │
│ - 只是过滤? → filter │
│ - 又要过滤又要转换? → flatMap │
│ - 一条变多条? → flatMap │
└────────────────────────────────────────────────────────────────────┘
三、分区算子
3.1 keyBy:按Key分组(最重要!)
作用:将数据按指定的 key 进行分组,相同 key 的数据会进入同一个分区。
java
// 示例1:按字段分组
DataStream<Order> orders = ...;
KeyedStream<Order, String> keyedOrders = orders.keyBy(order -> order.getUserId());
// 同一用户的订单会到同一个分区
// 示例2:使用lambda
DataStream<Tuple2<String, Integer>> tuples = ...;
KeyedStream<Tuple2<String, Integer>, String> keyed = tuples.keyBy(t -> t.f0);
// 示例3:多字段联合作为key
KeyedStream<Order, Tuple2<String, String>> keyed = orders.keyBy(
order -> Tuple2.of(order.getUserId(), order.getProductId())
);
// 同一用户买同一商品的订单会到同一分区
keyBy 分区原理:
hash(key) % numPartitions
输入流:
用户A订单 ──┐
用户B订单 ──┼─→ keyBy(userId) ─→ ┌─────────────┐ ─→ 分区0:用户A的所有订单
用户A订单 ──┤ │ Hash分区 │ ─→ 分区1:用户B的所有订单
用户C订单 ──┤ └─────────────┘ ─→ 分区2:用户C的所有订单
用户B订单 ──┘
相同key一定到同一分区!
注意事项:
- keyBy 后得到的是
KeyedStream,可以使用聚合算子 - key 必须正确实现
hashCode()和equals() - 避免数据倾斜:key 分布要均匀
3.2 shuffle:随机分发
java
DataStream<Integer> shuffled = numbers.shuffle();
// 数据随机发送到下游的某个并行实例
3.3 rebalance:轮询分发
java
DataStream<Integer> rebalanced = numbers.rebalance();
// 轮询方式均匀分发到所有并行实例
// 适合解决数据倾斜问题
3.4 rescale:局部轮询
java
DataStream<Integer> rescaled = numbers.rescale();
// 只在相邻的上下游实例间轮询,不走网络
// 适合上下游并行度有倍数关系时
3.5 broadcast:广播
java
DataStream<Integer> broadcasted = numbers.broadcast();
// 每条数据发送到所有并行实例
// 适合小数据量的全局配置
3.6 分区策略对比
┌────────────────────────────────────────────────────────────────────┐
│ 分区策略可视化 │
│ │
│ Forward(默认,并行度相同时): │
│ 上游0 ──→ 下游0 │
│ 上游1 ──→ 下游1 │
│ │
│ Shuffle(随机): │
│ 上游0 ──╲ ╱──→ 下游0 │
│ ╳ │
│ 上游1 ──╱ ╲──→ 下游1 │
│ │
│ Rebalance(全局轮询): │
│ 上游0 ──→ 下游0 → 下游1 → 下游2 → 下游0 ... │
│ 上游1 ──→ 下游1 → 下游2 → 下游0 → 下游1 ... │
│ │
│ Rescale(局部轮询): │
│ 上游0 ──→ 下游0, 下游1(循环) │
│ 上游1 ──→ 下游2, 下游3(循环) │
│ │
│ Broadcast(广播): │
│ 上游0 ──→ 下游0, 下游1, 下游2(所有) │
└────────────────────────────────────────────────────────────────────┘
四、聚合算子
聚合算子需要先 keyBy,在 KeyedStream 上使用。
4.1 reduce:归约
作用:将相同 key 的数据两两合并,最终得到一个结果。
java
// 示例1:求和
DataStream<Tuple2<String, Integer>> wordCounts = ...;
DataStream<Tuple2<String, Integer>> result = wordCounts
.keyBy(t -> t.f0)
.reduce((t1, t2) -> Tuple2.of(t1.f0, t1.f0 + t2.f1));
// ("hello", 1), ("hello", 1), ("world", 1)
// → ("hello", 2), ("world", 1)
// 示例2:找最大值
DataStream<Order> maxOrderByUser = orders
.keyBy(Order::getUserId)
.reduce((o1, o2) -> o1.getAmount() > o2.getAmount() ? o1 : o2);
// 每个用户金额最大的订单
reduce 工作原理:
key=A: ──1────2────3────4──→
│ │ │ │
└──┬──┘ │ │
│ │ │
1+2=3 │ │
└────┬──┘ │
│ │
3+3=6 │
└────┬──┘
│
6+4=10
输出: ──1────3────6────10──→
4.2 简单聚合:sum/min/max/minBy/maxBy
java
// 基于Tuple的聚合(按字段位置)
DataStream<Tuple3<String, String, Integer>> data = ...;
// (用户ID, 商品ID, 金额)
// sum:对指定字段求和
data.keyBy(t -> t.f0).sum(2); // 按用户求总金额
// min/max:返回指定字段的最小/最大值,其他字段不保证
data.keyBy(t -> t.f0).min(2); // 只保证f2是最小的,f1可能不对
// minBy/maxBy:返回指定字段最小/最大的那条完整记录
data.keyBy(t -> t.f0).minBy(2); // 返回金额最小的完整记录
min vs minBy 的区别:
java
// 假设输入:
// ("user1", "productA", 100)
// ("user1", "productB", 50)
// min(2) 输出:("user1", "productA", 50) // f1可能不对!
// minBy(2) 输出:("user1", "productB", 50) // 完整的最小记录
4.3 aggregate:自定义聚合
作用:最灵活的聚合方式,可以自定义累加逻辑和输出格式。
java
/**
* AggregateFunction<IN, ACC, OUT>
* IN: 输入类型
* ACC: 累加器类型
* OUT: 输出类型
*/
public class AverageAggregator implements AggregateFunction<Order, Tuple2<Long, Long>, Double> {
// 创建累加器
@Override
public Tuple2<Long, Long> createAccumulator() {
return Tuple2.of(0L, 0L); // (总金额, 订单数)
}
// 累加
@Override
public Tuple2<Long, Long> add(Order order, Tuple2<Long, Long> acc) {
return Tuple2.of(acc.f0 + order.getAmount(), acc.f1 + 1);
}
// 获取结果
@Override
public Double getResult(Tuple2<Long, Long> acc) {
return acc.f1 == 0 ? 0.0 : (double) acc.f0 / acc.f1;
}
// 合并累加器(窗口合并时用)
@Override
public Tuple2<Long, Long> merge(Tuple2<Long, Long> a, Tuple2<Long, Long> b) {
return Tuple2.of(a.f0 + b.f0, a.f1 + b.f1);
}
}
// 使用
DataStream<Double> avgAmounts = orders
.keyBy(Order::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new AverageAggregator());
五、多流操作算子
5.1 union:合并同类型流
作用:将多个类型相同的流合并成一个流。
java
DataStream<Order> orderStream1 = ...; // 来自Kafka
DataStream<Order> orderStream2 = ...; // 来自文件
DataStream<Order> orderStream3 = ...; // 来自数据库
// 合并多个流
DataStream<Order> allOrders = orderStream1
.union(orderStream2)
.union(orderStream3);
// 或者一次性合并
DataStream<Order> allOrders = orderStream1.union(orderStream2, orderStream3);
union:
流1: ──●────●────●──→
流2: ──■────■────■──→
流3: ──▲────▲──→
↓
合并: ──●──■──●──▲──■──●──▲──■──→
特点:
- 必须是相同类型
- 合并后顺序不保证
- 可以合并多个流
5.2 connect:连接不同类型流
作用:连接两个类型可以不同的流,之后用 CoMap 或 CoFlatMap 处理。
java
// 两个不同类型的流
DataStream<Order> orderStream = ...;
DataStream<Rule> ruleStream = ...; // 规则流(控制流)
// 连接
ConnectedStreams<Order, Rule> connected = orderStream.connect(ruleStream);
// 使用 CoMap 处理
DataStream<String> result = connected.map(new CoMapFunction<Order, Rule, String>() {
private Rule currentRule;
@Override
public String map1(Order order) {
// 处理订单流
if (currentRule != null && order.getAmount() > currentRule.getThreshold()) {
return "Large order: " + order.getId();
}
return "Normal order: " + order.getId();
}
@Override
public String map2(Rule rule) {
// 处理规则流
this.currentRule = rule;
return "Rule updated: " + rule.getName();
}
});
connect:
流1(Order): ──●────●────●──→
流2(Rule): ──■────■──→
↓ connect
ConnectedStream: 可以分别处理两种类型
↓ coMap/coFlatMap
输出流: ──○────○────○──→
典型应用场景:
- 动态规则:业务流 + 规则流
- 维表关联:主流 + 维度流
- 控制流:数据流 + 控制信号流
5.3 connect + broadcast:广播连接
java
// 规则流广播到所有并行实例
BroadcastStream<Rule> broadcastRules = ruleStream
.broadcast(ruleStateDescriptor);
// 连接主流和广播流
DataStream<Result> result = orderStream
.connect(broadcastRules)
.process(new BroadcastProcessFunction<Order, Rule, Result>() {
@Override
public void processElement(Order order, ReadOnlyContext ctx, Collector<Result> out) {
// 处理订单,可以读取广播状态
Rule rule = ctx.getBroadcastState(ruleStateDescriptor).get("current");
// ... 处理逻辑
}
@Override
public void processBroadcastElement(Rule rule, Context ctx, Collector<Result> out) {
// 更新广播状态
ctx.getBroadcastState(ruleStateDescriptor).put("current", rule);
}
});
5.4 join:窗口连接
java
// 两个订单流按用户ID关联
DataStream<Tuple2<Order, Payment>> joined = orders
.join(payments)
.where(Order::getOrderId) // orders的key
.equalTo(Payment::getOrderId) // payments的key
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.apply((order, payment) -> Tuple2.of(order, payment));
5.5 coGroup:分组连接
java
// coGroup比join更灵活,可以处理左/右流没有匹配的情况
DataStream<String> result = orders
.coGroup(payments)
.where(Order::getOrderId)
.equalTo(Payment::getOrderId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.apply(new CoGroupFunction<Order, Payment, String>() {
@Override
public void coGroup(Iterable<Order> orders, Iterable<Payment> payments,
Collector<String> out) {
// orders和payments是同一窗口内、相同key的所有数据
// 可以处理一对多、多对多等复杂关系
}
});
六、富函数与生命周期
6.1 什么是富函数?
普通函数(如 MapFunction)只能处理数据。富函数(RichFunction) 提供更多能力:
- 生命周期方法:
open()和close() - 获取运行时上下文:并行度、子任务索引等
- 访问状态
6.2 富函数示例
java
public class MyRichMapFunction extends RichMapFunction<String, String> {
private transient Connection dbConnection;
private transient ValueState<Integer> countState;
@Override
public void open(Configuration parameters) throws Exception {
// 初始化资源,只在任务启动时调用一次
dbConnection = DriverManager.getConnection("jdbc:mysql://...");
// 获取运行时信息
int subtaskIndex = getRuntimeContext().getIndexOfThisSubtask();
int parallelism = getRuntimeContext().getNumberOfParallelSubtasks();
System.out.println("SubTask " + subtaskIndex + "/" + parallelism + " started");
// 初始化状态
ValueStateDescriptor<Integer> descriptor =
new ValueStateDescriptor<>("count", Integer.class);
countState = getRuntimeContext().getState(descriptor);
}
@Override
public String map(String value) throws Exception {
// 使用数据库连接
// 使用状态
Integer count = countState.value();
if (count == null) count = 0;
countState.update(count + 1);
return value + "_" + count;
}
@Override
public void close() throws Exception {
// 清理资源,任务结束时调用
if (dbConnection != null) {
dbConnection.close();
}
}
}
6.3 生命周期图
┌────────────────────────────────────────────────────────────────────┐
│ RichFunction 生命周期 │
│ │
│ 任务启动 │
│ │ │
│ ↓ │
│ ┌────────┐ │
│ │ open() │ ← 初始化资源、获取状态、一次性准备工作 │
│ └────────┘ │
│ │ │
│ ↓ │
│ ┌────────────────────────────────────────┐ │
│ │ 处理数据(循环) │ │
│ │ map() / flatMap() / filter() / ... │ ← 每条数据调用一次 │
│ └────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────┐ │
│ │ close() │ ← 清理资源、释放连接 │
│ └─────────┘ │
│ │ │
│ ↓ │
│ 任务结束 │
└────────────────────────────────────────────────────────────────────┘
6.4 常用富函数类
| 普通函数 | 富函数版本 |
|---|---|
| MapFunction | RichMapFunction |
| FlatMapFunction | RichFlatMapFunction |
| FilterFunction | RichFilterFunction |
| ReduceFunction | RichReduceFunction |
| ProcessFunction | 本身就是富函数 |
七、侧输出流
7.1 什么是侧输出?
侧输出(Side Output) 允许从一个算子输出多个流,适合:
- 分流:把数据分成多个类别
- 处理异常数据
- 处理迟到数据
7.2 使用ProcessFunction实现侧输出
java
// 定义侧输出标签
final OutputTag<Order> bigOrderTag = new OutputTag<Order>("big-order"){};
final OutputTag<Order> smallOrderTag = new OutputTag<Order>("small-order"){};
final OutputTag<String> errorTag = new OutputTag<String>("error"){};
// 使用ProcessFunction分流
SingleOutputStreamOperator<Order> mainStream = orders
.process(new ProcessFunction<Order, Order>() {
@Override
public void processElement(Order order, Context ctx, Collector<Order> out) {
try {
if (order.getAmount() > 10000) {
// 大订单 -> 侧输出
ctx.output(bigOrderTag, order);
} else if (order.getAmount() < 100) {
// 小订单 -> 侧输出
ctx.output(smallOrderTag, order);
} else {
// 普通订单 -> 主输出
out.collect(order);
}
} catch (Exception e) {
// 异常数据 -> 错误侧输出
ctx.output(errorTag, "Error processing: " + order.getId());
}
}
});
// 获取各个流
DataStream<Order> normalOrders = mainStream;
DataStream<Order> bigOrders = mainStream.getSideOutput(bigOrderTag);
DataStream<Order> smallOrders = mainStream.getSideOutput(smallOrderTag);
DataStream<String> errors = mainStream.getSideOutput(errorTag);
// 分别处理
bigOrders.addSink(new BigOrderAlertSink());
smallOrders.addSink(new SmallOrderSink());
errors.addSink(new ErrorLogSink());
侧输出示意:
┌─→ bigOrderTag → 大订单流
│
输入流 ──→ ProcessFunction ──→ 主流 ──┼─→ smallOrderTag → 小订单流
(amount判断) │
└─→ errorTag → 错误流
7.3 窗口迟到数据侧输出
java
OutputTag<Order> lateDataTag = new OutputTag<Order>("late-data"){};
DataStream<OrderSummary> result = orders
.keyBy(Order::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(1))
.sideOutputLateData(lateDataTag) // 迟到数据输出到侧输出
.aggregate(new OrderAggregator());
// 获取迟到数据单独处理
DataStream<Order> lateOrders = result.getSideOutput(lateDataTag);
八、算子链控制
8.1 什么是算子链?
算子链(Operator Chain) 是 Flink 的优化:把多个算子合并到一个 Task 中执行,减少线程切换和网络传输。
优化前:
Source ──网络──→ Map ──网络──→ Filter ──网络──→ Sink
│ │ │ │
线程1 线程2 线程3 线程4
优化后(算子链):
┌───────────────────────────────┐ ┌─────────┐
│ Source → Map → Filter │──网络──→│ Sink │
│ (同一线程) │ │ │
└───────────────────────────────┘ └─────────┘
线程1 线程2
8.2 算子链的条件
自动chain的条件:
- 上下游并行度相同
- 数据传输方式是 Forward
- 在同一个 SlotSharingGroup
- 没有被禁用
8.3 手动控制算子链
java
// 1. 禁用当前算子的chain(不和上游chain)
stream.map(...).disableChaining();
// 2. 从当前算子开始新的chain
stream.map(...).startNewChain();
// 3. 全局禁用算子链
env.disableOperatorChaining();
// 4. 设置SlotSharingGroup(不同组不能chain)
stream.map(...).slotSharingGroup("groupA");
stream.filter(...).slotSharingGroup("groupB");
8.4 什么时候需要手动控制?
| 场景 | 操作 |
|---|---|
| 某算子特别重,需要单独监控 | disableChaining() |
| 排查性能问题,想看各算子耗时 | disableChaining() |
| 资源隔离,防止互相影响 | 不同 SlotSharingGroup |
九、总结
这篇文章我们全面学习了 Flink 的 Transformation 算子:
核心算子速查表
| 算子 | 作用 | 输入→输出 |
|---|---|---|
| map | 一对一转换 | 1→1 |
| flatMap | 一对多转换 | 1→0~N |
| filter | 过滤 | 1→0或1 |
| keyBy | 按key分组 | DataStream→KeyedStream |
| reduce | 归约聚合 | KeyedStream→DataStream |
| sum/min/max | 简单聚合 | KeyedStream→DataStream |
| union | 合并同类型流 | N个流→1个流 |
| connect | 连接不同类型流 | 2个流→ConnectedStream |
选择建议
数据转换?
├─ 一对一 → map
├─ 一对多 → flatMap
└─ 过滤 → filter
数据分组?
└─ keyBy → 然后用聚合算子
多流处理?
├─ 同类型合并 → union
└─ 不同类型连接 → connect
需要生命周期?
└─ 使用 RichXxxFunction
需要分流?
└─ ProcessFunction + 侧输出
下一篇文章,我们将学习 Flink 数据输出 Sink,看看处理完的数据怎么写入 Kafka、MySQL、Redis 等存储系统。
热门专栏推荐
- Agent小册
- Java基础合集
- Python基础合集
- Go基础合集
- 大数据合集
- 前端小册
- 数据库合集
- Redis 合集
- Spring 全家桶
- 微服务全家桶
- 数据结构与算法合集
- 设计模式小册
- Ai工具小册
等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持
文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊
希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏
如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟