开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

04 AOF日志:宕机了,Redis如何避免数据丢失?

发表于 2021-10-29

1.AOF日志如何实现

  • 一旦服务器宕机,内存中的数据全部丢失
  • 解决办法
+ 从后端数据库恢复数据


    - 对数据压力巨大(缓存雪崩)
    - 导致请求延时增加
  • redis持久化方案\
+ AOF(Append Only File)日志
+ RDB快照
  • 数据的写前日志(Write Ahead Log, WAL),先写日志,数据库崩溃后可以根据日志进行恢复
+ redo log重做日志,记录修改后的数据(直接保存数据)
  • AOF日志刚好相反,是写后日志,把数据写入内存后才记录日志(因为避免检查开销,redis向AOF写日志时避免指令检查)(命令执行成功后写)(不会阻塞当前操作!!!)
+ 记录的是redis的命令,以文本的形式保存
+ set testkey testvalue
+ \*3”表示当前命令有三个部分\
+ 每部分都是由“$+数字”开头,后面紧跟着具体的命令、键或值
  • 潜在的风险
+ 执行完命令后宕机,命令和数据有丢失风险\


    - 如果不能通过数据库恢复,就无法恢复了
+ 执行完命令后,写磁盘导致的阻塞
+ 调优方向:redis同步到磁盘的操作

2.三种写回策略

  • Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘\
+ 数据基本不丢失
+ 落盘操作影响主线程
  • Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘\
+ 其他两种的折中方案
+ 数据量控制在1s以内
+ 主线程性能和避免数据丢失取了一个折中
  • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘\
+ 落盘时机由操作系统掌控,数据丢失不太清楚

\

3.日志文件太大怎么办

  • 随着AOF文件越来越大,存在AOF的性能问题
+ 文件系统对文件大小的控制,无法保存过大文件
+ 文件太大,之后追加命令效率会变低
+ 文件太大导致回放慢
  • AOF重写机制
+ 根据现状创建一个新的AOF文件
+ 重写机制具有多变一的功能,旧日志的多条记录合并成一条命令
  • 即使重写后的日志也很大,会导致主线程的阻塞吗?

\

4.AOF重写会阻塞吗

  • 重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降\
  • 重写的过程总结为“一个拷贝,两处日志”\
  • “一个拷贝”就是指,每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。\

\

5.总结

  • 所有的设计都是配套的(写后日志+回放+聚合操作)
  • 它提供了 AOF 日志的三种写回策略,分别是 Always、Everysec 和 No\
  • 为了避免日志文件过大,Redis 还提供了 AOF 重写机制,直接根据数据库里数据的最新状态,生成这些数据的插入命令\
  • 三种写回策略体现了系统设计中的一个重要原则 ,即 trade-off,或者称为“取舍”,指的就是在性能和可靠性保证之间做取舍\
  • Redis 的单线程设计,这些命令操作只能一条一条按顺序执行,这个“重放”的过程就会很慢了

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

python中创建和使用迭代器 迭代 迭代器和可迭代对象 转

发表于 2021-10-29

迭代

在计算机中,迭代一般是指反复重复循环,直到到达某个条件为止。在python中可以理解为,能用于for循环的,是可以迭代的。

迭代 iterative

可迭代对象 iterable

迭代器 iterator

迭代器和可迭代对象

一般来说,我们认为能够用于for循环的,就是可迭代对象,能使用next()调用下一个对象的,就是迭代器。这里我们可以发现,之前我们提到的生成器,也是迭代器。

当然,在python中,我们也可以通过代码来判断一个对象是否是可迭代对象,或者是迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
python复制代码from collections.abc import Iterable, Iterator


def check_iterable(x, name):
if isinstance(x, Iterable):
print(name, "是可迭代对象")
else:
print(name, "不是可迭代对象")


def check_iterator(x, name):
if isinstance(x, Iterator):
print(name, "是迭代器")
else:
print(name, "不是迭代器")


if __name__ == "__main__":
t1 = [i for i in range(5)]
check_iterable(t1, "列表")
check_iterator(t1, "列表")

t2 = (i for i in range(5))
check_iterable(t2, "生成器")
check_iterator(t2, "生成器")

t3 = {i: i + 1 for i in range(5)}
check_iterable(t3, "字典")
check_iterator(t3, "字典")

我们执行这个程序,得到了这样的结果

列表 是可迭代对象

列表 不是迭代器

生成器 是可迭代对象

生成器 是迭代器

字典 是可迭代对象

字典 不是迭代器

转换为迭代器

同样是可以通过for循环遍历,为什么生成器就是迭代器,但是列表和字典就不是迭代器呢?

原因是,迭代器从一开始的时候,并没有得到全部的结果,而是通过调用next()得到了下一个结果。虽然迭代器可以被遍历,列表同样也可以被遍历,但是,它们不是一回事。比如,之前我们在生成器中提到的,迭代器可以表示一个无穷无尽的数列,但是列表显然不可以,所以,列表也不会是迭代器。

但是,我们可以通过iter(),创建迭代器对象,比如说,我们通过iter()将列表转换为迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python复制代码from collections.abc import Iterator


def check_iterator(x, name):
if isinstance(x, Iterator):
print(name, "是迭代器")
else:
print(name, "不是迭代器")


if __name__ == "__main__":
t1 = [i for i in range(5)]
check_iterator(t1, "列表转换前")

t2 = iter(t1)
check_iterator(t2, "列表转换后")

print(t2)

print(next(t2))

从这里,我们可以看出,转换前的列表不是迭代器,使用iter转换后是迭代器,同时也可以使用next()函数。

创建迭代器

如果你要创建一个类作为迭代器,那么要实现两个方法

iter() 该方法返回一个迭代器,此处可以返回自己

next() 该方法返回下一个迭代器对象

其中,如果你要为迭代器设置终止标志的话,需要抛出StopIteration异常,通过for循环遍历该迭代器时,会在StopIteration处结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
python复制代码from collections.abc import Iterator


def check_iterator(x, name):
if isinstance(x, Iterator):
print(name, "是迭代器")
else:
print(name, "不是迭代器")


class XiaIterator:
"""
作者:瞎老弟
时间:2021-10-29
联系方式:qq1413274264
说明:一个自建的迭代器类
"""

def __init__(self, num):
self.num = num

def __iter__(self):
self.s = 0
return self

def __next__(self):
if self.s < self.num:
self.s += 1
return self.s
else:
# 通过for循环使用,会在StopIteration处停止
# 如果是通过next()调用,会在最后抛出StopIteration异常
raise StopIteration


if __name__ == "__main__":
for i in XiaIterator(20):
print(i)

check_iterator(XiaIterator(20), "瞎老弟自建的迭代器类")

补充说明

你可能在其他地方,也能看到老版本的写法,如下所示

from collections.abc import Iterator 新版本是这样写的

from collections import Iterator 老版本是这样写的

这两种方式没什么区别,是一样的,只不过后面的那种在python的新版本中不再允许被使用了而已,如果你使用的是3.8以及以前的python版本,使用老版本的写法也是一样的。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文教会你如何进行Golang服务优化 1、概述 2、性能优

发表于 2021-10-29

1、概述

嗨喽,大家好呀!我是简凡,一位游走于各互联网大厂间的新时代农民工。对于C端在线业务,服务的稳定性和吞吐量常常是评估一个系统的重要指标,所以本文将从以下4点进行展开,逐步讲解golang中如何进行性能优化。

  1. 为什么要做性能优化
  2. 性能优化基础
  3. 优化思路
  4. 常见的优化场景

2、性能优化的目的(Why?)

我们常常在以下时候考虑到性能优化:

  1. 日常优化系统:
    1. 接口相应时间优化,以满足对上游的SLA
    2. CPU优化,保证在线业务cpu idl处于一个较高水平,降低业务量突增对系统稳定性带来的冲击
    3. 内存优化,减少内存占用,释放多余的服务器资源
  2. 解决线上业务问题:
    1. 接口相应超时
    2. CPU利用率飙升

3、性能优化基础(What?)

3.1 性能优化指标

在Golang服务中,我们常常从以下4点触发去做服务的优化:

  • CPU profile:报告程序的 CPU 使用情况,按照一定频率去采集应用程序在 CPU 和寄存器上面的数据
  • Memory Profile(Heap Profile):报告程序的内存使用情况
  • Block Profiling:报告 goroutines 不在运行状态的情况,可以用来分析和查找死锁等性能瓶颈
  • Goroutine Profiling:报告 goroutines 的使用情况,有哪些 goroutine,它们的调用关系是怎样的
  1. 性能分析过程(How?)

4.1 如何获取性能快照

golang中有两种类型的应用,工具性应用和服务型应用,工具性型应用的main函数仅一段时间,我们本地跑单元测试的性能测试其实原理就是应用的这种。服务型应用为长期存活的后端应用,例如RPC服务,HTTP服务,我们后端系统通常都是服务型应用。

4.1.1 工具型应用获取CPU快照

测试Demo如下,这里用了一个快排的例子,应用执行结束后,就会生成一个文件,保存了我们的 CPU profiling 数据。得到采样数据之后,使用go tool pprof工具进行 CPU 性能分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
go复制代码package main

import (
"math/rand"
"os"
"runtime/pprof"
"time"
)

func generate(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}
func bubbleSort(nums []int) {
for i := 0; i < len(nums); i++ {
for j := 1; j < len(nums)-i; j++ {
if nums[j] < nums[j-1] {
nums[j], nums[j-1] = nums[j-1], nums[j]
}
}
}
}

func main() {
pprof.StartCPUProfile(os.Stdout)
defer pprof.StopCPUProfile()
n := 10
for i := 0; i < 5; i++ {
nums := generate(n)
bubbleSort(nums)
n *= 10
}
}

这里使用的runtime/pprof这个分析工具,需要指定快照打印的位置,这里打印到标准输出了。可以会与程序中的打印冲突。我们可以自己实现写到文件中,这里可以用另一个开源工具替代github.com/pkg/profile,它会生成一个日志快照文件到临时目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
go复制代码package main

import (
"math/rand"
"github.com/pkg/profile"
"time"
)

func generate(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}
func bubbleSort(nums []int) {
for i := 0; i < len(nums); i++ {
for j := 1; j < len(nums)-i; j++ {
if nums[j] < nums[j-1] {
nums[j], nums[j-1] = nums[j-1], nums[j]
}
}
}
}

func main() {
defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
n := 10
for i := 0; i < 5; i++ {
nums := generate(n)
bubbleSort(nums)
n *= 10
}
}

4.1.1 服务型应用CPU分析

如果你的应用程序是一直运行的,比如 web 应用,那么可以使用net/http/pprof库,它能够在提供 HTTP 服务进行分析。这样你的 HTTP 服务都会多出/debug/pprof endpoint,访问它会得到类似下面的内容:

1
2
3
4
5
6
7
8
9
10
go复制代码package main

import (
"net/http"
_ "net/http/pprof"
)

func main() {
http.ListenAndServe("0.0.0.0:8000", nil)
}

image.png

现在数据已经可以采集了,那如何获取快照呢?我们上一步的操作,在后台起了一个http server服务,我们直接点击ui中的链接就可以拿到内存快照了,例如点击profile,我们就可以拿到一个30s的CPU快照,是一个*.pb.gz类型的二进制文件,可用于我们后面的分析。

  • /debug/pprof/profile:访问这个链接会自动进行 CPU profiling,持续 30s,并生成一个文件供下载
  • /debug/pprof/heap: Memory Profiling 的路径,访问这个链接会得到一个内存 Profiling 结果的文件
  • /debug/pprof/block:block Profiling 的路径
  • /debug/pprof/goroutines:运行的 goroutines 列表,以及调用关系

4.2 go tool分析性能快照

不管是工具型应用还是服务型应用,我们使用相应的 pprof 库获取数据之后,下一步的都要对这些数据进行分析,我们可以使用go tool pprof命令行工具。
go tool pprof最简单的使用方式为:

1
go复制代码go tool pprof [binary] [source]

其中:

  • binary 是应用的二进制文件,用来解析各种符号;例如:go tool pprof -http=:9999 /Users/xxxx/pprof/pprof.samples.cpu.001.pb.gz
  • source 表示 profile 数据的来源,可以是本地的文件,也可以是 http 地址。此方式会在命令窗口中按照交互模式例如:go tool pprof http://127.0.0.1:8000/debug/pprof/profile

注意事项: 获取的 Profiling 数据是动态的,要想获得有效的数据,请保证应用处于较大的负载(比如正在生成中运行的服务,或者通过其他压测工具模拟访问压力)。否则如果应用处于空闲状态,得到的结果可能没有任何意义。

可以增加些参数来获取更多信息,例如:

1
2
3
4
go复制代码# 我们想获取70s的内存快照,可以增加-seconds参数:
gotool pprof -seconds 70 http://127.0.0.1:8912/debug/pprof/profile
# 指定http接口,可以在ui上看到内存快照,参见本文4.2.2
gotool pprof -http=0.0.0.0:8234 http://127.0.0.1:8912/debug/pprof/profile

4.2.1 直连服务分析

go tool + 线上服务http接口地址的方式:

1
go复制代码go tool pprof http://127.0.0.1:8000/debug/pprof/profile

执行上面的代码会进入交互界面如下:

1
2
3
4
5
6
go复制代码runtime_pprof $ go tool pprof cpu.pprof
Type: cpu
Time: Jun 28, 2019 at 11:28am (CST)
Duration: 20.13s, Total samples = 1.91mins (568.60%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

我们可以在交互界面输入top3来查看程序中占用 CPU 前 3 位的函数:

1
2
3
4
5
6
7
8
go复制代码(pprof) top3
Showing nodes accounting for 100.37s, 87.68% of 114.47s total
Dropped 17 nodes (cum <= 0.57s)
Showing top 3 nodes out of 4
flat flat% sum% cum cum%
42.52s 37.15% 37.15% 91.73s 80.13% runtime.selectnbrecv
35.21s 30.76% 67.90% 39.49s 34.50% runtime.chanrecv
22.64s 19.78% 87.68% 114.37s 99.91% main.logicCode

其中:

  • flat:当前函数占用 CPU 的耗时
  • flat:: 当前函数占用 CPU 的耗时百分比
  • sun%:函数占用 CPU 的耗时累计百分比
  • cum:当前函数加上调用当前函数的函数占用 CPU 的总耗时
  • cum%:当前函数加上调用当前函数的函数占用 CPU 的总耗时百分比
  • 最后一列:函数名称

在大多数的情况下,我们可以通过分析这五列得出一个应用程序的运行情况,并对程序进行优化。
我们还可以使用list 函数名命令查看具体的函数分析,例如执行list logicCode查看我们编写的函数的详细分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go复制代码(pprof) list logicCode
Total: 1.91mins
ROUTINE ================ main.logicCode in .../runtime_pprof/main.go
22.64s 1.91mins (flat, cum) 99.91% of Total
. . 12:func logicCode() {
. . 13: var c chan int
. . 14: for {
. . 15: select {
. . 16: case v := <-c:
22.64s 1.91mins 17: fmt.Printf("recv from chan, value:%v\n", v)
. . 18: default:
. . 19:
. . 20: }
. . 21: }
. . 22:}

通过分析发现大部分 CPU 资源被 17 行占用,我们分析出 select 语句中的 default 没有内容会导致上面的case v:=<-c:一直执行。我们在 default 分支添加一行time.Sleep(time.Second)即可。
​

4.2.2 快照文件+图形化工具

这种快照文件的方式好处是更加直观,可以通过图形化界面来分析:
想要查看图形化的界面首先需要安装 graphviz 图形化工具。Mac:brew install graphviz
接下来,可以用 go tool pprof 分析这份数据

1
go复制代码go tool pprof -http=:9999 cpu.pprof

访问 localhost:9999,可以看到这样的页面:

当然我们还可以选择VIEW,然后看火焰图:

至此,我们就成功的获取了每个函数占用的CPU时间了,下面就可以对占用较长的函数(平顶山部分)进行优化了。
​

5、常见性能优化手段

5.1 使用高效的性能包

5.1.1 Json解析

我们将Json数据存放到Redis时,取出时需要将其解析为Struct,但go官方自带的库性能较差,所以常常出现瓶颈,可选择github.com/json-iterat… 替换标准库的 encoding/json(该库主要的优化手段详见:jsoniter.com/benchmark.h… json-iterator 宣传的性能如下图:

​

5.1.2 深拷贝

还有时我们需要在项目中使用到深拷贝的场景,可以参考这篇文章,深拷贝性能对比:www.yuque.com/jinsesihuan…。
​

5.2 空间换时间

  1. 对于常见的Json解析问题,Redis大key问题,我们可以进行多级缓存,将Redis中的大key数据缓存到内存中,这里别忘了考虑带来的缓存一致性问题。
  2. 对于一些map,slice,尽量在初始化时指定大小,减少内存的重新分配

5.3 字符串拼接

字符串的拼接优先考虑bytes.Buffer。由于string类型是一个不可变类型,但拼接会创建新的string。GO中字符串拼接常见有如下几种方式,对性能要求很高的服务尽量使用bytes.Buffer进行字符串拼接

  • string + 操作 :导致多次对象的分配与值拷贝
  • fmt.Sprintf :会动态解析参数,效率好不哪去
  • strings.Join :内部是[]byte的append
  • bytes.Buffer :可以预先分配大小,减少对象分配与拷贝

image.png
使用strconv包替代fmt.Sprintf的格式化方式,性能比对见:www.cnblogs.com/yumuxu/p/40…
​

5.4 异步处理

既然选用了Golang,自然要用到它简单易用的并发机制啦,我们可以把一些不影响主流程的操作完全可以异步化,例如发送邮件、写日志等。可以把一些业务场景并行处理,例如你要一次性读取多个文件。
​

6、总结

代码层面的优化,是 us 级别的,而针对业务对存储进行优化,可以做到 ms 级别的,所以优化越靠近应用层效果越好。对于代码层面,优化的步骤是:

  1. 利用压测工具模拟场景所需的真实流量。压测工具推荐使用 github.com/wg/wrk 或 github.com/adjust/go-w…
  2. pprof 等工具查看服务的 CPU、MEM 耗时
  3. 锁定平顶山逻辑,看优化可能性:异步处理,空间换时间,使用高性能包 等
  4. 局部优化完写 benchmark 工具查看优化效果
  5. 整体优化完回到步骤一,重新进行 压测+pprof 看效果,看耗时能否满足要求,如果无法满足需求,那就换存储吧~😭

​ 后续我会给大家出一篇关于Golang服务的代码开发建议,我们下期见,Peace😘

我是简凡,一个励志用最简单的语言,描述最复杂问题的新时代农民工。求点赞,求关注,如果你对此篇文章有什么疑惑,欢迎在我的微信公众号中留言,我还可以为你提供以下帮助:

  • 帮助建立自己的知识体系
  • 互联网真实高并发场景实战讲解
  • 不定期分享Golang、Java相关业内的经典场景实践
    我的博客:besthpt.github.io/

微信公众号:”简凡丶”

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

C++11 make_shared C++ make_sha

发表于 2021-10-29

C++ make_shared的使用:

1
2
3
4
5
6
c复制代码
shared_ptr<string> p1 = make_shared<string>(10, '9');

shared_ptr<string> p2 = make_shared<string>("hello");

shared_ptr<string> p3 = make_shared<string>();

尽量使用make_shared初始化

C++11 中引入了智能指针, 同时还有一个模板函数 std::make_shared 可以返回一个指定类型的 std::shared_ptr, 那与 std::shared_ptr 的构造函数相比它能给我们带来什么好处呢 ?

make_shared初始化的优点

1、提高性能

shared_ptr 需要维护引用计数的信息:
强引用, 用来记录当前有多少个存活的 shared_ptrs 正持有该对象. 共享的对象会在最后一个强引用离开的时候销毁( 也可能释放).
弱引用, 用来记录当前有多少个正在观察该对象的 weak_ptrs. 当最后一个弱引用离开的时候, 共享的内部信息控制块会被销毁和释放 (共享的对象也会被释放, 如果还没有释放的话).
如果你通过使用原始的 new 表达式分配对象, 然后传递给 shared_ptr (也就是使用 shared_ptr 的构造函数) 的话, shared_ptr 的实现没有办法选择, 而只能单独的分配控制块:

使用shred_ptr初始化

如果选择使用 make_shared 的话, 情况就会变成下面这样:

image.png

std::make_shared(比起直接使用new)的一个特性是能提升效率。使用std::make_shared允许编译器产生更小,更快的代码,产生的代码使用更简洁的数据结构。考虑下面直接使用new的代码:

1
arduino复制代码std::shared_ptr<Widget> spw(new Widget);

很明显这段代码需要分配内存,但是它实际上要分配两次。每个std::shared_ptr都指向一个控制块,控制块包含被指向对象的引用计数以及其他东西。这个控制块的内存是在std::shared_ptr的构造函数中分配的。因此直接使用new,需要一块内存分配给Widget,还要一块内存分配给控制块。

如果使用std::make_shared来替换

1
ini复制代码auto spw = std::make_shared<Widget>();

一次分配就足够了。这是因为std::make_shared申请一个单独的内存块来同时存放Widget对象和控制块。这个优化减少了程序的静态大小,因为代码只包含一次内存分配的调用,并且这会加快代码的执行速度,因为内存只分配了一次。另外,使用std::make_shared消除了一些控制块需要记录的信息,这样潜在地减少了程序的总内存占用。

对std::make_shared的效率分析可以同样地应用在std::allocate_shared上,所以std::make_shared的性能优点也可以扩展到这个函数上。

2、 异常安全

我们在调用processWidget的时候使用computePriority(),并且用new而不是std::make_shared:

1
2
scss复制代码processWidget(std::shared_ptr<Widget>(new Widget),  //潜在的资源泄露 
computePriority());

就像注释指示的那样,上面的代码会导致new创造出来的Widget发生泄露。那么到底是怎么泄露的呢?调用代码和被调用函数都用到了std::shared_ptr,并且std::shared_ptr就是被设计来阻止资源泄露的。当最后一个指向这儿的std::shared_ptr消失时,它们会自动销毁它们指向的资源。如果每个人在每个地方都使用std::shared_ptr,那么这段代码是怎么导致资源泄露的呢?

答案和编译器的翻译有关,编译器把源代码翻译到目标代码,在运行期,函数的参数必须在函数被调用前被估值,所以在调用processWidget时,下面的事情肯定发生在processWidget能开始执行之前:

表达式“new Widget”必须被估值,也就是,一个Widget必须被创建在堆上。
std::shared_ptr(负责管理由new创建的指针)的构造函数必须被执行。
computePriority必须跑完。
编译器不需要必须产生这样顺序的代码。但“new Widget”必须在std::shared_ptr的构造函数被调用前执行,因为new的结构被用为构造函数的参数,但是computePriority可能在这两个调用前(后,或很奇怪地,中间)被执行。也就是,编译器可能产生出这样顺序的代码:

1
2
3
arduino复制代码执行“new Widget”。
执行computePriority。
执行std::shared_ptr的构造函数。

如果这样的代码被产生出来,并且在运行期,computePriority产生了一个异常,则在第一步动态分配的Widget就会泄露了,因为它永远不会被存放到在第三步才开始管理它的std::shared_ptr中。

使用std::make_shared可以避免这样的问题。调用代码将看起来像这样:

1
2
scss复制代码processWidget(std::make_shared<Widget>(),       //没有资源泄露
computePriority());

在运行期,不管std::make_shared或computePriority哪一个先被调用。如果std::make_shared先被调用,则在computePriority调用前,指向动态分配出来的Widget的原始指针能安全地被存放到被返回的std::shared_ptr中。如果computePriority之后产生一个异常,std::shared_ptr的析构函数将发现它持有的Widget需要被销毁。并且如果computePriority先被调用并产生一个异常,std::make_shared就不会被调用,因此这里就不需要考虑动态分配的Widget了。

如果使用std::unique_ptr和std::make_unique来替换std::shared_ptr和std::make_shared,事实上,会用到同样的理由。因此,使用std::make_unique代替new就和“使用std::make_shared来写出异常安全的代码”一样重要。

缺点

构造函数是保护或私有时,无法使用 make_shared

make_shared 虽好, 但也存在一些问题, 比如, 当我想要创建的对象没有公有的构造函数时, make_shared 就无法使用了, 当然我们可以使用一些小技巧来解决这个问题, 比如这里 How do I call ::std::make_shared on a class with only protected or private constructors?

对象的内存可能无法及时回收

make_shared 只分配一次内存, 这看起来很好. 减少了内存分配的开销. 问题来了, weak_ptr 会保持控制块(强引用, 以及弱引用的信息)的生命周期, 而因此连带着保持了对象分配的内存, 只有最后一个 weak_ptr 离开作用域时, 内存才会被释放. 原本强引用减为 0 时就可以释放的内存, 现在变为了强引用, 若引用都减为 0 时才能释放, 意外的延迟了内存释放的时间. 这对于内存要求高的场景来说, 是一个需要注意的问题.

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

鸡兔同笼问题时间复杂度的演进

发表于 2021-10-29

鸡兔同笼,是中国古代著名典型趣题之一,记载于《孙子算经》之中。这四句话的意思是:有若干只鸡兔同在一个笼子里,从上面数,有35个头,从下面数,有94只脚。问笼中各有多少只鸡和兔?下面我们将从时间复杂度的角度来演进代码。

O(n^2)

下面的代码大家应该都能看懂,我们使用双层循环进行遍历,把x当成鸡的数量,把y当初兔子的数量。根据鸡有两条腿,兔子有4条腿的计算规则,那么很容易得出x + y == 35 and 2 * x + 4 * y == 94这个数学公式,经过暴力遍历,我们就可以求出鸡和兔子的数量了。

1
2
3
4
5
scss复制代码def check1():
for x in range(1, 36):
for y in range(1, 36):
if x + y == 35 and 2 * x + 4 * y == 94:
print(x, y)

O(n)

现在我们转变思路,根据我们初中学习的二元一次方程可以得知,当我们设鸡为x时,那么兔子的数量便为35-x,同样我们根据鸡有两条腿,兔子有4条腿的计算规则,此时我们只需要一层遍历就可以解决问题,并且时间复杂度很好的控制在了一个线性阶。

1
2
3
4
5
6
arduino复制代码def check2():
x = 35
for i in range(1, x + 1):
if 2 * i + 4 * (35 - i) == 94:
print(i, 35 - i, i)
break

O(1)

咋一看下面的代码可能有点懵,现在我们来解释一下。为什么使用头的数量(35)乘2呢?我们来考虑一个极端方向,就是一共有35个头,那么我们把这35个都先当成鸡,因为鸡有两条退。那么此时35*2=70,但是我们还有一个前提条件94条腿,此时94-70=24,那么这24条腿只能兔子的,我们在将24 / 2= 12,即为兔子的数量。那么此法即为抬腿法:假设鸡和兔子都抬起2只脚,所有抬起的脚:35×2=70只,那么地上剩余的脚:94-70=24只,此时地上的脚只能是兔子的脚(因为鸡的脚已经抬完了),而且每只兔子有2只脚着地。

1
2
3
4
5
ini复制代码def check3():
x = (94 - 35 * 2) / 2
y = 35 - x
if int(x) == x:
print(x, y)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java 防抖动函数的实现

发表于 2021-10-29

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

概述

目前在开发一个文档监控类工具时遇到一个问题,希望在文档编写过程中不对文档做备份,而在文档编写结束时再备份,这就需要一个防抖函数。

防抖函数,就是指触发事件后在n 秒内函数只能执行一次,如果在n 秒内又触发了事件,则会重新计算函数执行时间。 简单的说,当一个动作连续触发,则只执行最后一次。

实现

我们可以使用ScheduledExecutorService的schedule方法实现,
为每个任务分配一个唯一的 key,使用ConcurrentHashMap来存储 key-future,使用future的 cancel 方法来取消线程任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
java复制代码package org.example.simple;

import java.util.concurrent.*;

/**
* @author Catch
* @since 2021-10-29
*/
public class Debounce {

private static final ScheduledExecutorService SCHEDULE = Executors.newSingleThreadScheduledExecutor();

// 使用 ConcurrentHashMap 来存储 Future
private static final ConcurrentHashMap<Object, Future<?>> DELAYED_MAP = new ConcurrentHashMap<>();

/**
* 抖动函数
*/
public static void debounce(final Object key, final Runnable runnable, long delay, TimeUnit unit) {
final Future<?> prev = DELAYED_MAP.put(key, SCHEDULE.schedule(() -> {
try {
runnable.run();
} finally {
// 如果任务运行完,则从 map 中移除
DELAYED_MAP.remove(key);
}
}, delay, unit));
// 如果任务还没运行,则取消任务
if (prev != null) {
prev.cancel(true);
}
}

/**
* 停止运行
*/
public static void shutdown() {
SCHEDULE.shutdownNow();
}

public static void main(String[] args) {
// 1,2 为每个任务的唯一 key
Debounce.debounce("1", () -> {System.out.println(11);}, 3, TimeUnit.SECONDS);
Debounce.debounce("1", () -> {System.out.println(22);}, 3, TimeUnit.SECONDS);
Debounce.debounce("1", () -> {System.out.println(33);}, 3, TimeUnit.SECONDS);
Debounce.debounce("2", () -> {System.out.println(44);}, 3, TimeUnit.SECONDS);
Debounce.debounce("2", () -> {System.out.println(44);}, 3, TimeUnit.SECONDS);
Debounce.debounce("2", () -> {System.out.println(44);}, 3, TimeUnit.SECONDS);
}

}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

java agent的一次踩坑实验之旅 何为java age

发表于 2021-10-29

何为java agent

java agent也可以称之为java代理,相当于在JVM级别做了AOP支持,我们可以在运行main方法之前对程序进行修改或新增逻辑。

怎么实现

java agent可以通过两种方式实现

  1. 静态挂载
    首先需要实现premain方法,在这里会通过Instrumentation进行类修改,所以还需要实现Transformer,(本篇不对Instrumentation做详细阐述,可以自行百度学习。)
1
2
3
4
5
6
7
typescript复制代码public class PreMainDemoAgent {

public static void premain(String agentArgs, Instrumentation instrumentation) {
System.out.println("preagent agentArgs:" + agentArgs);
instrumentation.addTransformer(new PreMainTransformer(), true);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
csharp复制代码public class PreMainTransformer implements ClassFileTransformer {

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if ("com/vernunft/agent/demo/controller/TestController".equals(className)) {
System.out.println("=================premain testController transform==================");
try {
CtClass demoClass = ClassPool.getDefault().get("com.vernunft.agent.demo.controller.TestController");
CtMethod testMethod = demoClass.getDeclaredMethod("test");
String methodBody = "return "premain";";
testMethod.setBody(methodBody);
demoClass.detach();
System.out.println("----处理好了-----");
return demoClass.toBytecode();
} catch (Throwable e) {
System.out.println("=========testController transform error========" + e.getMessage());
e.printStackTrace();
}
}
return null;
}
}
  1. 动态挂载
    动态挂载则需要定义agentmain方法,同样也需要定义对应的Transformer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
csharp复制代码public class AgentMainDemo {
public static void agentmain(String agentArgs, Instrumentation instrumentation) throws UnmodifiableClassException, NotFoundException, CannotCompileException, IOException, ClassNotFoundException {
System.out.println("agentmain agentArgs:" + agentArgs);
Class[] loadedClass = instrumentation.getAllLoadedClasses();
Class demoClass = null;
for (Class clazz : loadedClass) {
if (clazz.getName().equals("com.vernunft.agent.demo.controller.TestController")) {
demoClass = clazz;
}
}
instrumentation.addTransformer(new AgentMainTransformer(), true);
instrumentation.retransformClasses(demoClass);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class AgentMainTransformer implements ClassFileTransformer {

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if ("com/vernunft/agent/demo/controller/TestController".equals(className)) {
System.out.println("=================agentmain testController transform========================");
try {
ClassPool classPool = ClassPool.getDefault();
CtClass demoClass = classPool.getCtClass("com.vernunft.agent.demo.controller.TestController");
CtMethod testMethod = demoClass.getDeclaredMethod("test");
String methodBody = "return "agentmain";";
testMethod.setBody(methodBody);
demoClass.detach();
return demoClass.toBytecode();
} catch (Exception e) {
System.out.println("error" + e);
e.printStackTrace();
}
}
return null;
}
}

代码编写完就可以进行配置了,这边直接应用maven进行配置,也可以定义在META-INF文件上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
xml复制代码<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<!-- 静态挂载 -->
<Premain-Class>com.vernunft.agent.demo.PreMainDemoAgent</Premain-Class>
<!-- 动态挂载 -->
<Agent-Class>com.vernunft.agent.demo.AgentMainDemo</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
</manifestEntries>
</archive>
</configuration>
</plugin>

原本以为到这里这个插件就可以使用了,直接mvn clean install打包成jar,在目标项目直接挂载跑起来测试,就遇到坑了。

这里我直接在项目里面跑,jvm配置如下:
image.png

遇到的问题&解决方案

  • NoClassDefFoundError:javassist/ClassPool
    image.png
    这个现象是这样:执行后,我的agent代码跑到

CtClass demoClass = ClassPool.getDefault().get(“com.vernunft.agent.demo.controller.TestController”);

就没有继续往下执行,而且刚开始我用Exception捕获,日志竟然也打不出来,最后我用Throwable才打印出错误。
可以发现这打包时就没把依赖jar打入,得在maven上加入配置,把引的jar一起打入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
xml复制代码<!-- 将依赖jar打入项目的插件-->
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<id>shade-when-package</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<!-- 在这里把需要打入的jar包引进来-->
<include>org.javassist:javassist</include>
</includes>
</artifactSet>
<shadeSourcesContent>true</shadeSourcesContent>
</configuration>
</execution>
</executions>
</plugin>

标签的值为这里标记的

image.png
重新打包执行测试成功,按照预期返回结果。

  • 动态挂载时报com.sun.tools.attach.AttachNotSupportedException: no providers installed
    解决方案:在目标项目的pom文件增加tools.jar依赖
1
2
3
4
5
6
7
xml复制代码<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.5.0</version>
<scope>system</scope>
<systemPath>D:/Program Files/Java/jdk1.8.0_60/lib/tools.jar</systemPath>
</dependency>

重新构建目标项目执行,发现问题解决~

最后附上目标项目的测试文件,动态挂载就是通过指定进程id,挂载对应的插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
less复制代码@RestController
@RequestMapping("/agent")
public class TestController {
@GetMapping("/attach/{pid}")
public String attachAgent(@PathVariable String pid) throws IOException, AttachNotSupportedException,
AgentLoadException, AgentInitializationException {
// JVM attach机制
VirtualMachine virtualMachine = VirtualMachine.attach(pid);
virtualMachine.loadAgent("E:\coding\lbb-agent\target\lbb-agent.jar");
return "success";
}

@GetMapping("/test")
public String test() {
return "test";
}

Over,因为踩坑才能学习更多的东西,fighting。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

ES远程集群设置与使用 0 背景 1 配置远程集群 2 使用

发表于 2021-10-29

0 背景

在生产环境中,我们先后搭建了两套ES集群,一套用于系统日志的采集,包括k8s组件的日志、网关日志、服务pod日志等;另一套用于业务系统信息的存储,如埋点信息、业务日志等。现在,我们想要统一的将两套集群管理起来,使用统一的Kibana面板对外做数据展示。在网上搜索解决方案,看到了ES的远程集群功能,在此记录一下。

Elasticsearch在5.3版本中引入了Cross Cluster Search(CCS 跨集群搜索)功能,用来替换掉要被废弃的Tribe Node。类似Tribe Node,Cross Cluster Search用来实现跨集群的数据搜索。跨集群搜索使您可以针对一个或多个远程集群运行单个搜索请求 。例如,您可以使用跨集群搜索来过滤和分析存储在不同数据中心的集群中的日志数据。

1 配置远程集群

ES提供两种远程集群配置方案,一种是直接在ES的配置文件elasticsearch.yml中进行配置,另一种是使用API的方式进行设置。

1.1 配置文件配置方式

修改elasticsearch.yml,添加如下内容

1
2
3
4
5
6
7
8
yaml复制代码search:
  remote:
      cluster01:
          seeds: 192.168.10.101:9300
          seeds: 192.168.10.102:9300
          seeds: 192.168.10.103:9300
          transport.compress: true
          skip_unavailable: true
  • cluster01:集群名称,自定义即可
  • seeds:集群的节点列表,可以配置一个或多个
  • transport.ping_schedule: 使用ping检测连接状态的时间间隔
  • skip_unavailable:跨集群搜索是否跳过不可用集群

1.2 API配置方式

使用Cluster Settings API进行设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
json复制代码PUT _cluster/settings
{
 "persistent": {
   "cluster": {
     "remote": {
       "cluster01": {
         "skip_unavailable": false,
         "mode": "sniff",
         "proxy_address": null,
         "proxy_socket_connections": null,
         "server_name": null,
         "seeds": [
           "192.168.10.101:9300",
           "192.168.10.102:9300",
           "192.168.10.103:9300"
        ],
         "node_connections": 3
      }
    }
  }
}
}

配置参数同上

在此我更倾向于使用API的方式设置远程集群,这样更方便对远程集群进行修改。

1.3 查看远程集群状态

使用GET _remote/info请求进行查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
json复制代码{
 "cluster01" : {
   "connected" : true,
   "mode" : "sniff",
   "seeds" : [
       "192.168.10.101:9300",
       "192.168.10.102:9300",
       "192.168.10.103:9300"
  ],
   "num_nodes_connected" : 3,
   "max_connections_per_cluster" : 3,
   "initial_connect_timeout" : "30s",
   "skip_unavailable" : false
}
}
​

1.4 删除远程集群

如果设置有误或不想用了,可以将远程集群删除,其实就是将seeds设置为空。

1
2
3
4
5
6
7
8
9
10
11
12
json复制代码PUT _cluster/settings
{
 "persistent": {
   "cluster": {
     "remote": {
       "cluster01": {
         "seeds": null
      }
    }
  }
}
}

1.5 使用kibana管理远程集群

如果使用了kibana,那么设置远程集群更加简单,只需要在页面上操作即可。

打开Stack Management,左侧目录找到远程集群或remote cluster,在打开的页面点击添加远程集群

填写集群名称与节点信息

image-20211029163006356

保存即可

2 使用远程集群搜索

远程集群权限设置

  1. 在远程集群上创建与本地集群同名的角色
  2. 远程集群上的角色要赋予对应索引的read与read_cross_cluster权限,否则本地集群访问该索引时连接会被拒绝。

查询远程集群

查询远程集群的索引需要指定集群名称

1
bash复制代码GET /cluster_name:index/_search

同时查询多个集群

1
bash复制代码GET /cluster_name:index,cluster_name:index/_search

同时查询所有集群

1
javascript复制代码GET */index/_search

Kibana中创建索引模式

在Kibana中创建索引模式时,也要指定集群名

1
makefile复制代码cluster_name:index*

之后使用就跟本地集群的索引模式一样了。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

终于搞懂为什么重写equals就需要重写hashCode了

发表于 2021-10-29

为什么重写equals就需要重写hashCode

在阿里巴巴开发手册-华山版 中有一处强制规定:

【强制】关于 hashCode 和 equals 的处理,遵循如下规则:

1) 只要覆写 equals,就必须覆写 hashCode。

2) 因为 Set 存储的是不重复的对象,依据 hashCode 和 equals 进行判断,所以 Set 存储的对象必须覆

写这两个方法。

3) 如果自定义对象作为 Map 的键,那么必须覆写 hashCode 和 equals。

说明:String 已覆写 hashCode 和 equals 方法,所以我们可以愉快地使用 String 对象作为 key 来使 用。

我们可以在Object类中hashCode方法上面的注释得到以下几点:

  1. 同一个对象反复调用hashCode方法,返回的结果都是一致的。(前提是这个对象没被修改过)
  2. 如果使用equals方法比较得到两个对象相等,那么这两个对象去调用hashCode方法返回的值是相等的
  1. 两个对象的hashCode值相等,两个对象不一定相等。

由此,我们可以得出结论:

  1. 如果两个对象equals完结果相同,那么hashCode值肯定相等。
  2. 如果两个对象的hashCode值不相等,那么这两个对象也不相同。

我们在来看看Object类中的equals方法,equals是用来判断两个对象是否相等的,从官方注释可以知道它具备的性质:

  1. 它是自反的:对于任何非空引用值x , x.equals(x)应该返回true 。
  2. 它是对称的:对于任何非空引用值x和y , x.equals(y)应返回true当且仅当y.equals(x)返回true 。
  1. 它是可传递的:对于任何非空引用值x 、 y和z ,如果x.equals(y)返回true并且y.equals(z)返回true ,那么x.equals(z)应该返回true 。
  2. 它是一致的:对于任何非空引用值x和y , x.equals(y)多次调用始终返回true或始终返回false ,前提是没有修改对象的equals比较中使用的信息。对于任何非空引用值x , x.equals(null)应返回false 。

在注释中,有一句特别注意的话就是,每当重写此方法时,通常都需要重写hashCode方法,以维护hashCode方法的一般约定,即相等的对象必须具有相等的哈希码。

其实到了这里我们应该也能够明白了,hashCode和equals这两个方法根本就是配套使用的。那么要如何理解重写equals方法就必须要重写hashCode方法呢?

我们从Object类中可以看到,equals方法其实就是直接使用==进行比较的。所以当两个引用对象指向同一个在堆中被分配内存的实例时,则才会返回true,否则则返回false。

但是,Object类中的equals方法原本的功能在我们真实开发中是远远不够的,当两个对象引用的堆实例不同时,它就会认为这两个对象不相等;但是我们在真是开发中,我们认为两个对象是否相等,取决与其实例的属性是否相等。

举个栗子:

1
2
3
java复制代码A a1 = new A("123");
A a2 = new A("123");
a1.equals(a2);

上面这段代码,如果在没有重写equals方法的时候,得到的结果会是false。因为在堆中会是两个不同的实例,但是在真实开发中,我们想要比较的是属性内容是否相等,也就是说,此时a1和a2属性内容是一样的,所以a1.equals(a2)则需要返回true。这种情况其实也很常见,例如常见的String类,就是重写了equals方法,当内容相等则返回true。

那么又回到原来的问题,在hashCode方法的官方注释我们可以知道:如果两个对象equals完返回true,那么hashCode值肯定相等。所以,我们在重写equals的时候,则必须重写hashCode,保证其hashCode值相等,不然就违背了原来的原则了。

总之,只要重写了equals方法,那么就需要重写hashCode方法。我们看到Java中的String类,这个类在Java集合中用到很多,例如我们平时使用的HashMap/Set等等集合,都是经常性的使用String类型去当作key来进行使用的。

Ⅰ. 如果String没有重写equals,也就是继承了Object类的equals方法的话,则如果String类型为key的时候,相同的字符串,也可能会被当成两个不一样的key,从而难以辨别。

Ⅱ. 如果String重写了equals,没有重写hashCode。那么也会出现,同一个key出现在不同的位置,因为集合中常用哈希函数来计算key的位置,在没有重写hashCode的情况下,则同一个key会出现hashCode值不同而导致位置不同。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

t-io 375 发布,口碑炸裂的国产网络编程框架 标题

发表于 2021-10-29

标题说明

看到”口碑炸裂“四字,应该又有不少”闻风而至”的同学要来”口吐芬芳”,所以先上3张”炸裂封条”

image.png

image.png

image.png
如果3张”炸裂封条”还不够,那就再上一张王炸”唵嘛呢叭咪吽”,没错,就是封印孙悟空500年的”六字大明咒“

image.png
言归正传,t-io其实是一位三流程序员写的国产网络编程框架,为了自我证明t-io的优秀,这位程序员还用t-io写了HTTP服务器、WebSocket服务器,再后来这位程序用t-io、tio-http、tio-websocket做了一个类似微信的即时通讯软件—-[谭聊](这位程序员水平不怎么样,但足够自恋,所以用了自己姓命名这款软件)。这里有本屌丝级《t-io技术白皮书》。珍藏白皮书,不是为了更好的了解t-io,而是为日后吐槽t-io甚至打败t-io积累原始资本^_^

本次更新内容

很是惭愧,又是一个没有bug可修复的版本,所以只能每隔俩月例行地把t-io的管理依赖升级一下(毕竟许多用户就在用着t-io的依赖管理),保持升级的频繁度也有利于让新用户知道作者还一直在维护这个框架

最新maven坐标

tio-core

image.png

tio-http

image.png

tio-websocket

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…454455456…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%