普通视图

发现新文章,点击刷新页面。
昨天以前nickChenyx

Linux 查看进程启动命令和环境变量

作者 nickChen
2022年10月14日 22:11
  1. 查询目标进程,通过 ps -ef | grep {target process name} 查询进程 pid(ps -aux 也行)
  2. cat /proc/{pid}/cmdline 可以查询到进程的启动参数,但是现在的格式不大好读,可以用下面的命令优化下输出
  3. cat /proc/{pid}/cmdline | tr "\0" "\n" 用空格分割各个运行时参数,方便阅读
  4. cat /proc/{pid}/environ | tr "\0" "\n 可以查询到进程运行时的环境变量

认识 Scrum 和产品开发流程

作者 nickChen
2022年7月24日 16:00

什么是 Scrum

WHAT IS SCRUM

简而言之,Scrum 需要一个管理者营造出这样一种环境:

  1. 一个产品负责人将复杂问题排序放入一个任务队列中
  2. Scrum 团队在一个 Sprint 周期内选出一部分工作完成,实现价值增长
  3. Scrum 团队和利益相关者检查这个 Sprint 周期的产出结果,并为下个 Sprint 进行调整
  4. 重复执行以上动作

Scrum 术语词汇表

Scrum Glossary

专业名词名词解释
Burn-Down Chart燃尽图,用于表示产品任务队列的剩余任务数量
Burn-Up Chart燃烧图,用于表示已完成的任务数量
Daily Scrum每日Scrum汇报,大约十五分钟,在一个 Sprint 周期的每天都需要执行。Developer 会确认未来二十四小时的开发计划。在这个过程中,会检查上个 Daily Scrum 的结果,并调整这个 Sprint 接下来的工作。这有助于提高团队人员之间的协作和效率。Daily Scrum 可以很好的减少 Sprint 的复杂度
Definition of Done产品任务完成的定义。任务队列中任务在执行时需要有明确的完成定义,这可以让团队对任务有更清晰的共同理解。如果一个产品任务不符合 Definition of Done,它就不可以被发布,也不能在 Sprint Review 中展示
Developer隶属于 Scrum 团队的任何一名成员。
Increment在 Sprint 期间产生的所有完整且有价值的工作就是 Scrum 的产出。所有的这些增量(Increment)的组合,就构成了一个产品
Product Backlog产品任务队列。Scrum 的产物由一个有序的任务清单组成,这个清单可以创造、维护、和维持一个产品。Product Backlog 由产品负责人(Product Owner)管理
Product Owner负责将产品的价值最大化,主要是通过逐步管理并向开发人员表达对产品的业务和功能期望
Product Goal产品目标描述了产品的未来状态,可以作为 Scrum 团队计划的一个目标。Product Goal 在 Product Backlog 中,Product Backlog 剩余的任务定义了如何来实现 Product Goal
Ready产品负责人(Product Owner)和开发人员(Developer)对在 Sprint 中引入的任务有共同的理解
Scrum Board一个实体面板,用于为 Scrum 团队提供可视化的信息,通常用于管理 Sprint Backlog
Scrum Master在 Scrum 团队中负责指导、辅导、教导和协助 Scrum 团队,确保对 Scrum 的正确理解和使用
Sprint在 Scrum 中的一个重要组成事件,时间通常为一个月或者更短,作为其他 Scrum 事件和活动的一个容器。Sprint 是连续运行的,没有空隙
Sprint Backlog在一个 Sprint 期间需要执行的任务清单,指明了这个 Sprint 周期内的产品目标
Sprint Goal对 Sprint 周期内需要完成的目标的简短描述
Sprint Planning一个 Scrum 事件,以八小时或者更短的时间来开始一个 Sprint。它的作用是让 Scrum 团队检查产品任务清单(Product Backlog)中,接下来最有价值的工作,并将这些工作设计到下个 Sprint 任务清单(Sprint Backlog)中
Sprint Retrospective一个 Scrum 事件,以三个小时或更短的时间来结束一个 Sprint。它的作用是让 Scrum 团队检查刚过去的 Sprint,并计划在未来的 Sprint 中进行改进
Sprint Review一个 Scrum 事件,以四小时或者更短的时间来总结刚过去的 Sprint 中的开发工作。它的作用是让 Scrum 团队和利益相关者检查 Sprint 的产出,评估所做的工作对实现产品目标的总体进展的影响,并更新产品任务清单,以使下个阶段的价值最大化
Stakeholder利益相关者,Scrum 团队的外部成员,会在 Sprint Review 中与 Scrum 团队进行积极互动,关心 Sprint 的增量产出(Increment)
Technical Debt技术债务
Velocity一个可选的,但是经常使用的指标,表明 Scrum 团队在 Sprint 期间将产品任务清单(Product Backlog)转化为产品增量(Increment)的数量,由开发人员跟踪,供 Scrum 团队使用

另外,当软件开发团队使用 Scrum 和敏捷编程时,也有些专业词汇。参考 Professional Scrum Developer Glossary

专业名词名词解释
User Story来自极限编程的敏捷软件开发实践,从终端用户的角度表达需求,强调口头交流。在 Scrum 中,它经常被用来表达产品任务清单(Product Backlog)上的一组任务

Scrum 工作流

图片来源:https://www.scrum.org/resources/what-is-scrum
scrumorg-scrum-framework-3000

本图描述了一个完整的 Scrum 工作流,其中的各个单元都在上文的名词解释中有提及。
图中的每个节点就是项目过程中我们需要关注的指标,节点之间流转就是管理人员需要介入的时间点。

从节点和流转的视角来看:

节点节点指标流入流出
产品任务队列(Product Backlog)待开发任务的总和,需要关注燃尽&燃烧的情况需要产品&项目负责人把关流入的需求流出是转入下一个开发周期(Sprint)需要确认下个周期的排期安排
周期开发计划(Sprint Planning)关注会议的时间和结论,需要控制好时间和验证结论的合理性需要确认优先级高的需求优先进入开发周期,但是需要关心需求的连贯性和整体性,我们的目标是本开发周期(Sprint)后的的产品增量(increment)能产生更大价值需要将最终决定的需求整理好进入开发周期任务队列 (Sprint Backlog)
开发周期任务队列(Sprint Backlog)进入到开发周期了,在这里依然要关注燃尽的情况,更重要的是需要对产品增量(Increament)负责,要将本开发周期的目标向最终产物对齐的同时,保障开发任务能按时交付必须是整合过后的优先级高的需求流入,进入此队列时,需要对其开发资源,确保交付周期和质量进入开发周期日会,检验成果
每日 Scrum 汇报(Daily Scrum)这是开发周期(Sprint)内的必要动作,日报需要关注昨天的开发情况,提早发现问题&暴露风险,也要规划今天的开发任务。这里并不是说在当天才确认当天的开发任务,而是对原定开发任务的一个补充。是根据之前的 Daily Scrum 已知问题和暴露风险,重新协调团队资源解决。这也是软件开发工作必须具备的提前量。需要关心昨天开发工作中遇到的问题,也包含产品上可能的突发变动,或者是目标微调。这些问题需要在当日的 Daily Scrum 上提出根据已知的问题和风险,重新协调团队资源解决问题,对当天的开发计划重新对齐
产品增量(Increment)关注产品功能和质量一个开发周期(Sprint)的最终产物验收确认可交付的产物
开发回顾(Sprint Review)评估产出的质量,找出上个周期开发不足,在下个周期时予以改正。评估本周期的工作对实现产品目标的总体进展的影响,并更新产品任务队列(Product Backlog),使下个阶段的开发价值最大化上个开发周期(Sprint)的各种信息找到团队问题和解法;评估产品目标进展和更新任务产品队列(Product Backlog)
开发回顾(Sprint Retrospective)这个节点和 Sprint Review 类似,如果细分开的话可以将上一个节点视为产品价值的 review,这个节点更偏向于团队工作的 review--

Scrum 实践

理论是如何被实践的?实践过程中会遇到什么问题?解决的方案是否结合了理论?

  1. 各个节点和流转的负责人是谁?有什么工具可以支撑?
  2. 实际工作中,涉及需求理解的多方对齐,有无流程上的保障?(角色需求复述)
  3. 实际开发过程中,一个 WEB/APP/OTHER 项目开发会有什么特别流程?(产品试用)
  4. 如何做产品价值的 Review?(AB、数据分析)
  5. 实际工作中会有多种角色,并且角色介入产品开发的时间各不相同,如何串联起各角色的时间?(各个时间线的排期,各团队的 leader 先一步确认需求和大致排期)

#TODO

从《实例化需求:团队如何交付正确的软件》寻找部分解法

漫谈 Golang 之 map

作者 nickChen
2022年7月23日 20:59

map 参数传递

当 map 作为参数被传递时,实际上传递的 map 的指针信息,传递后的修改会同步到函数外。

可以看到 m 被传递到了 onMap 函数中,但是最后在函数外的 m 也是被修改了的。

package mainimport "fmt"func main() {    m := map[int]int{}    opMap(m)    printMap(m)}func opMap(m map[int]int) {    for i := 0; i < 10; i++ {        m[i] = i        printMap(m)    }}func printMap(m map[int]int) {    fmt.Printf("len: %v, map: %v\n", len(m), m)}// Output:// len: 1, map: map[0:0]// len: 2, map: map[0:0 1:1]// len: 3, map: map[0:0 1:1 2:2]// len: 4, map: map[0:0 1:1 2:2 3:3]// len: 5, map: map[0:0 1:1 2:2 3:3 4:4]// len: 6, map: map[0:0 1:1 2:2 3:3 4:4 5:5]// len: 7, map: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6]// len: 8, map: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7]// len: 9, map: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8]// len: 10, map: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9]// len: 10, map: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9]

无法对 map value 取地址

如下的代码中,尝试获取 map value 的地址,会在编译时显示失败。

Why Go forbid taking the address of map member

package mainimport "fmt"func main() {    m := map[int]int{}    address := &m[0] // invalid operation: cannot take address of m[0] (map index expression of type int)    fmt.Println(address)}

漫谈 Golang 之 slice

作者 nickChen
2022年7月19日 22:39

Part1: array & slice

// define a slices := make([]int, 10)// define an arraya := [10]int{}
  • 定义 slice 第一种方式
    var s []int
  • 定义 slice 第二种方式
    s := make([]int, len, cap)
  • array
    var a [length]type// var a [10]int

Part2: 类型

package mainimport "fmt"func main() {    var a [8]int    printArray(a)}func printArray(a [10]int) {    fmt.Println(len(a))    fmt.Println(cap(a))}

以上代码中,printArray(a) 是否可以正常执行?答案是否定的,编译期就会提示错误

cannot use a (type [8]int) as type [10]int in argument to printArray

可以看到提示中变量 a 的类型是 type [8]int,而函数 printArray 要求的入参类型是 type [10]int。可见 array 的长度也是其类型的一部分!

Part3: slice grow

package mainimport "fmt"func main() {    var s []int    for i := 0; i < 1025; i++ {        s = append(s, i)    }    fmt.Println(len(s)) // 1025    fmt.Println(cap(s)) // 1280 = 1024*1.25}

slice 扩容的方式是:source code

  • cap < 1024 –> cap * 2
  • cap > 1024 –> cap * 1.25

append 多个参数的对于 slice 容量的影响是不同的,特殊case:

package mainimport "fmt"func main() {    var s1, s2, s3, s4, s5 []int    s1 = append(s1, 0) // len 1, cap 1    printSlice(s1)    s2 = append(s2, 0, 1) // len 2, cap 2    printSlice(s2)    s3 = append(s3, 0, 1, 2) // len 3, cap 3    printSlice(s3)    s4 = append(s4, 0, 1, 2, 3) // len 4, cap 4    printSlice(s4)    s5 = append(s5, 0, 1, 2, 3, 4) // len 5, cap 6    printSlice(s5)}func printSlice(s []int) {    fmt.Println(len(s))    fmt.Println(cap(s))}

Part4: slice append
如何快速完成一次 slice 数据填充?

  1. 声明一个 slice 直接开始 append
  2. 声明固定长度 slice 后开始 append
  3. 声明固定长度 slice 后,使用 index 进行数据填充

可以看到方法三是最快的。

对于大数组的赋值,常用的优化方式还有一种 BCE,可以参考这篇文章中的用法,不再赘述。golang 边界检查优化

package mainimport "testing"func BenchmarkAppend(b *testing.B) {    var s []int    for i := 0; i < b.N; i++ {        s = append(s, i)    }}func BenchmarkMakeSliceAppend(b *testing.B) {    s := make([]int, 0, b.N)    for i := 0; i < b.N; i++ {        s = append(s, i)    }}func BenchmarkIndex(b *testing.B) {    s := make([]int, b.N)    for i := 0; i < b.N; i++ {        s[i] = i    }}// Output// goos: darwin// goarch: amd64// cpu: Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz// BenchmarkAppend-8                457261122            14.93 ns/op// BenchmarkMakeSliceAppend-8       1000000000             1.407 ns/op// BenchmarkIndex-8                 1000000000             1.246 ns/op

Part5: slice 扩容带来的”意外”

当使用 index 修改 slice 时,可能会出现”时而生效时而不生效的情况”,究其原因是 slice 在 grow 的过程中重新分配了内存地址。

下面这个情况展示接收 slice 的函数 sliceAppend 修改了 index 但是在函数外不生效的情况。

package mainimport "fmt"func main() {    var s []int    s = append(s, 1)    printSlice(s)    sliceAppend(s)    printSlice(s)}func sliceAppend(s []int) {    s = append(s, 1) // 此处发生了扩容操作,导致 s 的内存地址改变    s[0] = 0    printSlice(s)}func printSlice(s []int) {    fmt.Printf("len: %v, cap: %v, val: %v\n", len(s), cap(s), s)}// Output:// len: 1, cap: 1, val: [1]// len: 2, cap: 2, val: [0 1]// len: 1, cap: 1, val: [1]

其实,扩容的发生导致函数内外的可见性也不一样了。和上个例子差不多的一个案例。
可以看到,此处两个 slice s 打印出来,结果是不一样的。看起来就是函数内的 s 比函数外的 s 数据更多了!换句话说,在实际场景中,很有可能因为如下这样的误操作,导致看似操作过 s,但是数据缺丢失了。

package mainimport "fmt"func main() {    var s []int    s = append(s, 1)    printSlice(s)    sliceAppend(s)    printSlice(s)}func sliceAppend(s []int) {    s = append(s, 1) // 此处发生了扩容操作,导致 s 的内存地址改变    printSlice(s)}func printSlice(s []int) {    fmt.Printf("len: %v, cap: %v, val: %v\n", len(s), cap(s), s)}// Output:// len: 1, cap: 1, val: [1]// len: 2, cap: 2, val: [1 1]// len: 1, cap: 1, val: [1]

!!如果发生了扩容,修改会在新的内存中!!

所以针对 slice 的操作,务必使用 append 函数返回的 slice 对象进行后续操作,避免出现奇怪的数据异常!

Part6: slice 序列化

如下案例所示,slice 的 zero value 经过默认的 json 库序列化结果是 null,但是初始化的 slice 经过默认的 json 库序列化结果就是 []

package mainimport (    "encoding/json"    "fmt")func main() {    var s []int    b, _ := json.Marshal(s)    fmt.Println(string(b))}// Output:// null
package mainimport (    "encoding/json"    "fmt")func main() {    s := []int{}    b, _ := json.Marshal(s)    fmt.Println(string(b))}// Output:// []
package mainimport (    "encoding/json"    "fmt")func main() {    s := make([]int, 0, 1)    b, _ := json.Marshal(s)    fmt.Println(string(b))}// Output:// []
package mainimport (    "encoding/json"    "fmt")func main() {    s := make([]int, 1)    b, _ := json.Marshal(s)    fmt.Println(string(b))}// Output:// [0]

TODO 如果有更多再补充

Golang 开源之 retry-go 使用指南

作者 nickChen
2022年6月12日 18:59

retry-go 实现非常优美的 retry 库
2022/06/15 仿写实现同样的功能 https://github.com/nickChenyx/retry-go-dummy

功能测试

  1. 定义了两种错误 SomeErr& AnotherErr ,用来测试 retry-go 库的不同函数
  2. 测试一个常见的 HTPP GET 场景
  3. retry.Do(func() error, ...opt) 使用 Do 函数立马开始进行 retry 操作,简单的使用一个 func() error 包括将要被 retry 的代码
  4. 多种 opt 之 retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration) 主要能力是提供每次重试延迟的时间
  5. 多种 opt 之 retry.OnRetry(func(n uint, err error) 主要能力是再触发 retry 操作的时候,前置执行该函数,可用于日志记录等
  6. 多种 opt 之 retry.RetryIf(func(err error) bool 主要能力是判断是否要触发 retry,可以根据不同的错误类型选择是否要进行 retry 操作
  7. 多种 opt 之 retry.Attempts(uint) 主要是设置重试次数,限制重试的时间
  8. 额外功能之 retry.BackOffDelay(n, err, config) 使用在 retry.DelayType(...) 中,可以设置指数级增长的 delay 时间
type SomeErr struct {    err        string    retryAfter time.Duration}func (err SomeErr) Error() string {    return err.err}type AnotherErr struct {    err string}func (err AnotherErr) Error() string {    return err.err}func TestHttpGet(t *testing.T) {    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {        fmt.Fprintln(w, "hello")    }))    defer ts.Close()    var body []byte    var retrySum uint    err := retry.Do(        func() error {            resp, err := http.Get(ts.URL)            ri := rand.Intn(10)            if ri < 3 {                err = SomeErr{                    err:        "some err",                    retryAfter: time.Second,                }            } else if ri < 6 {                err = AnotherErr{                    err: "another err",                }            }            if err == nil {                defer func() {                    if err := resp.Body.Close(); err != nil {                        panic(err)                    }                }()                body, err = ioutil.ReadAll(resp.Body)            }            return err        },        retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration {            switch e := err.(type) {            case SomeErr:                return e.retryAfter            case AnotherErr:                return retry.BackOffDelay(n, err, config)            default:                return time.Second            }        }),        retry.OnRetry(func(n uint, err error) { retrySum += 1 }),        retry.RetryIf(func(err error) bool {            switch err.(type) {            case SomeErr, AnotherErr:                return true            default:                return false            }        }),        retry.Attempts(3),    )    assert.NoError(t, err)    assert.NotEmpty(t, body)}

看看实现

声明 retry 行为

type RetryableFunc func() errorfunc Do(retryableFunc RetryableFunc, opts ...Option) error

这里声明了三部分内容:

  1. Do 函数的执行核心 retryableFunc,一个会返回 error 的简单函数

这里可以看出,待执行的任务会被 func() 包裹,没有额外的入参,但是可以抛出一个 error 作为任务异常的标志。后续重试行为依赖这个 error 信息

  1. Do 函数提供了扩展能力,此处用的是 Options 模式(可以看另一篇 Options-Pattern 文章了解更多)

  2. Do 函数返回了 error,此处描述的是整个 retry 结束过后,任务尚未成功,需要有一个结果

默认 retry 配置

从默认配置中探索 retry-go 库的设计思路

func newDefaultRetryConfig() *Config {    return &Config{        attempts:      uint(10),        delay:         100 * time.Millisecond,        maxJitter:     100 * time.Millisecond,        onRetry:       func(n uint, err error) {},        retryIf:       IsRecoverable,        delayType:     CombineDelay(BackOffDelay, RandomDelay),        lastErrorOnly: false,        context:       context.Background(),    }}

当 Do 函数的 options 为空时,该配置就是实际执行 Do 函数的运行时配置了。罗列一下配置项:

  • attempts -> 重试次数,默认 10 次,使用 uint 限制重试次数大于 0
  • delay -> 重试的间隔时间
  • maxJitter -> RandomDelay 函数的 delay 最大值设置,随机范围在 [0, maxJitter) 之间
  • onRetry -> 这是一个空函数,默认在每次重试前无动作
  • lastErrorOnly -> 表示是否只收集最后一个 error,反之则收集全部任务产生的 error 信息
  • context -> 设置一个无用的 context,但是可以传递一个具有超时配置的 context 进来,这样可以设置整个 retry 的全局超时时间
  • retryIf -> 这是判断是否要进行重试的函数,IsRecoverable 作用如下:
func IsRecoverable(err error) bool {    _, isUnrecoverable := err.(unrecoverableError)    return !isUnrecoverable}

可以看到这里当错误 err 是 unrecoverableError 时,就不会重试。也就是 retry-go 自定义了一个不可恢复的异常,同时提供了 Unrecoverable函数封装一个 unrecoverableError。如果用户知道了这个特性,就可以利用起来,从而中断重试。下面是 unrecoverableError 的定义:

type unrecoverableError struct {    error}func Unrecoverable(err error) error {    return unrecoverableError{err}}
  • delayType -> 设置延时时间的函数,组合了 BackOffDelay 指数级增长的延时和 RandomDelay 随机延时,从而达到总体上指数级增长但是具体数值又有波动的延时效果
// CombineDelay(BackOffDelay, RandomDelay),func CombineDelay(delays ...DelayTypeFunc) DelayTypeFunc {    const maxInt64 = uint64(math.MaxInt64)    return func(n uint, err error, config *Config) time.Duration {        var total uint64        for _, delay := range delays {            total += uint64(delay(n, err, config))            if total > maxInt64 {                total = maxInt64            }        }        return time.Duration(total)    }}

Golang 开源之 tparse 使用指南

作者 nickChen
2022年6月12日 18:14

tparse 分析和归纳 go test 输出的命令行工具

安装

go install github.com/mfridman/tparse@latest

tparse 能做什么

仅使用 go test -v 来显示测试结果,只是罗列了所有测试集合,没有归纳总结,不便于一眼明了。

➜  go test -race ./testtparse -v=== RUN   Test_sum=== RUN   Test_sum/return_2--- PASS: Test_sum (0.00s)    --- PASS: Test_sum/return_2 (0.00s)=== RUN   Test_sub=== RUN   Test_sub/return_0--- PASS: Test_sub (0.00s)    --- PASS: Test_sub/return_0 (0.00s)PASSok      code.byted.org/demo/testtparse  0.026s

使用 tparse 分析归纳 go test 的报告结论

➜  go test -race ./testtparse -json -cover | tparse -all+--------+---------+-------------------+------------+| STATUS | ELAPSED |       TEST        |  PACKAGE   |+--------+---------+-------------------+------------+| PASS   |    0.00 | Test_sum          | testtparse || PASS   |    0.00 | Test_sum/return_2 | testtparse || PASS   |    0.00 | Test_sub          | testtparse || PASS   |    0.00 | Test_sub/return_0 | testtparse |+--------+---------+-------------------+------------++--------+---------+--------------------------------+--------+------+------+------+| STATUS | ELAPSED |            PACKAGE             | COVER  | PASS | FAIL | SKIP |+--------+---------+--------------------------------+--------+------+------+------+| PASS   | 0.04s   | code.byted.org/demo/testtparse | 100.0% |    4 |    0 |    0 |+--------+---------+--------------------------------+--------+------+------+------+

Golang 单元测试之绕过 init panic

作者 nickChen
2022年6月2日 14:59

init 函数中发生 panic

在做单元测试的时候,当程序引入的包内有 init 函数并且抛出了 panic,如何修复这种场景?

第一反应肯定是能否 mock 掉出问题的函数,但是因为 mock 的执行顺序是在依赖包的 init 执行之后,所以 mock 生效前,init 函数就已经 panic 了。明显这样做是无效的。

以往的经验是在发生 panic 的 init 函数代码仓库中,新建一个测试分支,修改 init 中的逻辑避免 panic。然后再在单测代码中引入这个修复分支,才可以进行测试。在这里提供一个新的思路↓

利用 init 默认加载的顺序解决

新的方案,利用 init 加载顺序的机制,在前置运行的 init 函数中,mock 会发生 panic 的 init 函数场景。

我们尝试使用 init 加载顺序修复这个问题,如下的三个项目:

  1. import_panic_init 这个是苦主项目,他的引用了会产生 panic 的 init 函数的外部包
  2. panic_init 会产生 panic 的 init 的函数所在地
  3. util 无辜的工具库,被 panic_init 错误的使用产生了 panic
    .├── import_panic_init│   ├── a│   │   └── a.go│   ├── go.mod│   ├── go.sum│   └── main.go├── panic_init│   ├── go.mod│   └── panic_init.go└── util ├── go.mod └── util.go
    代码可以在仓库中获取:https://github.com/nickChenyx/code-repo/tree/main/golang/test_panic_init

在 util 包中,util.go 文件如下:

package utilimport "fmt"func IsTest(t *int) bool {    fmt.Println("util.isTestCall")    if t == nil {        panic("t can't be nil")    }    return *t == 0}

在 panic_init 包中,panic_init.go 文件如下:

package panic_initimport "fmt"import "util"func init() {    util.IsTest(nil) // 必定发生 panic    fmt.Println("panic_init run..")}

在 import_panic_init 包中,main.go 文件如下:

package mainimport (        "fmt"        _ "panic_init"        "util"        "bou.ke/monkey")func main() {        monkey.Patch(util.IsTest, func(t *int) bool {                return true        })        fmt.Println("main run...")}

可以看到此处 main 函数妄图 mock util.IsTest 函数,避免 panic 影响 fmt.Println(“main run…”) 的执行。

但是运行结果是:

$ go run main.go # 执行 import_panic_init.main 函数util.isTestCallpanic: t can't be nilgoroutine 1 [running]:util.IsTest(0x0)        .../projects/test_panic_init/util/util.go:8 +0x89panic_init.init.0()        .../projects/test_panic_init/panic_init/panic_init.go:7 +0x1b    exit status 2

可以看到 main 函数中的 mock 实际上未生效。修改 main.go 文件如下:

package mainimport (        _ "a" // 添加了这个 a 包,并且在 panic_init 包之前引入!这很重要        "fmt"        _ "panic_init"        "util"        "bou.ke/monkey")func main() {        monkey.Patch(util.IsTest, func(t *int) bool {                return true        })        fmt.Println("main run...")}

import_panic_init/a/a.go 中,定义了 mock 函数用于 mock util 包的函数如下:

package a import "util"import "bou.ke/monkey"import "fmt"func init() {    monkey.Patch(util.IsTest, func(t *int) bool {    return true    })    fmt.Printf("a init call util.IsTest: %v\n", util.IsTest(nil))}

然后再执行 main.go 如下:

$ go run main.go # 执行 import_panic_init.maina inita init call util.IsTest: truepanic_init run..main run...

可以看到此时 a 包的 init 先于 panic_init 包的 init 执行,所以 mock 函数先被执行,panic_init 中的 util.IsTest 调用被 mock 返回 true,而不会发生 panic!

有趣的是,如果将 main.go 中 import 的顺序调整,那么依然会发生 panic:

import (        "fmt"        _ "panic_init"        _ "a" // 在 panic_init 包之后引入,此时执行顺序在 panic_init 包之后        "util"        "bou.ke/monkey")

Golang 的执行顺序

import --> const --> var --> init()

一个 golang 文件中执行的顺序如上,先执行文件中定义的 import 包中的逻辑,再执行 const 常量定义,再执行 var 变量定义,再执行 init 函数。

具体可看:https://learnku.com/go/t/47135

Golang 开源之 semgroup 使用指南

作者 nickChen
2022年5月24日 11:32

semgroup 用于并发执行一组任务,提供限制协程数量、同步等待任务执行完成及错误信息传递的能力。不同于 errgroup ,semgroup 会执行所有任务,且收集任务产生的 error 信息。在全部任务执行完成后,将收集的 error 信息返回给开发者。

使用方法

快速使用请看官网的使用方法,此处不赘述

示例 TestDemo 中用到了如下功能,可以按需使用

  1. context 定义了超时时间,可以限制整个任务的执行时间

  2. 任务并行执行,且向外丢出了自定义 error

  3. 使用 errors.Iserrors.As 处理 error

  4. 使用了拓展的 ExportMultiError 函数将所有内部错误全部输出(附带错误中包含了关键信息如 taskId,可以在后续的程序中使用)

    func TestDemo(t *testing.T) { // 可控制任务整体的执行时间 timedCtx, cancel := context.WithTimeout(context.Background(), time.Hour) defer cancel() g := NewGroup(timedCtx, 1) fe := fooErr{taskId: 1, msg: "__foo__"} g.Go(func() error { return fe }) g.Go(func() error { return os.ErrClosed }) g.Go(func() error { return nil }) err := g.Wait() if err == nil {     t.Fatalf("g.Wait() should return an error") } var (     fbe fooErr ) // Is 可用于抛出错误的判断 if !errors.Is(err, fe) {     t.Errorf("error should be equal fooErr") } // Is 可用于抛出错误的判断 if !errors.Is(err, os.ErrClosed) {     t.Errorf("error should be equal os.ErrClosed") } // As 可以将错误检索出来 if !errors.As(err, &fbe) {     t.Error("error should be matched foobarErr") } // 通过上面 As 将错误信息取出,应该可以拿到失败的任务 id if fbe.taskId != 1 {     t.Error("fooErr task id should be 1") } me, isMultiError := ExportMultiError(err) if !isMultiError {     t.Error("err should be a multiError") } for _, e := range me {     t.Logf("range me: %v", e)     var fe fooErr     if errors.As(e, &fe) && fe.taskId != 1 {         t.Error("variable t.taskId should be 1")     } }}

补充上文使用到的 ExportMultiError 函数。

func ExportMultiError(err error) ([]error, bool) {    if err == nil {        return nil, false    }    switch err.(type) {    case multiError:        return err.(multiError), true    default:        return []error{err}, false    }}

思考

并行处理失败的任务有无必要返回具体的信息?原库中仅透出了 errors.Iserrors.As 两个函数供处理异常,实际上会不会在 error 中透出具体的任务信息,供错误失败时使用呢?
基于这种思考,先提供了一个 ExportmultiError 的函数解决这个问题。有无更好的方式,或者业界更通用的处理方案?

Go 设计模式之 Options-Pattern

作者 nickChen
2020年12月28日 10:36

Options Pattern 主要是使用于装配属性,让我们先来看看传统的属性装配方案。

结构体

type House struct {    Material     string    HasFireplace bool    Floors       int}

通过构造函数(Constructor)装配属性

// NewHouse("concrete", 5, true)func NewHouse(m string, f int, fp bool) *House {    return &House {        Material: m,        HasFireplace: fp,        Floors: f,    }}

可以看到此时通过一个自定义的构造函数装配属性,此时需装配的属性需要一次性全部填入,且构造函数的入参有顺序性,必须按照函数定义的顺序传入参数。另外,当需装配的属性过于多时,此时构造函数也会越来越冗长。

通过 func 作为参数传入构造函数

type HouseOption func(*House)func WithConcrete() HouseOption {    return func(h *House) {        h.Material = "concrete"    }}func WithoutFireplace() HouseOption {    return func(h *House) {        h.HasFireplace = false    }}func WithFloors(floors int) HouseOption {    return func(h *House) {        h.Floors = floors    }}func NewHouse(opts ...HouseOption) *House {    const (        defaultFloors       = 2        defaultHasFireplace = true        defaultMaterial     = "wood"    )    h := &House{        Material:     defaultMaterial,        HasFireplace: defaultHasFireplace,        Floors:       defaultFloors,    }    // Loop through each option    for _, opt := range opts {        // Call the option giving the instantiated        // *House as the argument        opt(h)    }    // return the modified house instance    return h}// build House with optionsh := NewHouse(  WithConcrete(),  WithoutFireplace(),  WithFloors(3),)

将 func 作为参数传入,一是方便了装配属性的复杂配置,二是不需要固定顺序的构造参数传入,三其实这样的实现方式也可以作为一个属性装配的切面,可以暗搓搓整点活儿。

这就是 Options Patter 了。

参考资料:

Docker 中的 Java 运行时内存限制配置

作者 nickChen
2020年12月25日 10:14

现在是2020年12月,在这个时间点 Java 在 Docker 容器中运行的内存限制已经有了明确的解决方案,此处做个记录。

JDK 10 引入了默认开启的参数 UseContainerSupport,同时这个特性也被 backport 到 JDK1.8 的 8u191 版本。也就是说 8u191 和更后面的 JDK 都可以通过开启 UseContainerSupport 来支持 cgroup 看到 cgroup 的内存限制。
再结合 MaxRAMPercentage 来动态算一个堆内存上限就足够了,这个值具体看服务用到的堆外内存和线程的使用量,一般无脑给个 75/80 都没问题。具体程序用多少内存合适,正确实践一定是先预估一个偏保守的值,在环境里跑一下,然后结合实际请求量和监控来持续调节 kubernetes/docker 的内存限制数值。

下面是个 spring boot(layered jar) 工程打包为镜像的 Dockerfile 示例

FROM openjdk:15WORKDIR applicationCOPY ./dependencies/ ./COPY ./spring-boot-loader/ ./COPY ./snapshot-dependencies/ ./COPY ./application/ ./ENV JAVA_OPTS='-Dspring.application.name=my-demo \-XX:+UseContainerSupport \-XX:MaxRAMPercentage=75.0 'ENTRYPOINT exec java $JAVA_OPTS org.springframework.boot.loader.JarLauncher

最后还要再额外留意下,自 jdk9 (8u131)开始,这些跟容器支持的参数有过几轮变迁。如果没有多关注 upstream 或者搜了国内 CSDN 低劣二手文,很容易被拐到坑里去,比如看到下面这些参数,基本都是废弃或用不到的,不用再理会:

UnlockExperimentalVMOptionsUseCGroupMemoryLimitForHeapUseCGroupMemoryLimitMaxRAMFractionMaxRAM

—— 引用自:李飘柔 https://www.zhihu.com/question/315793102/answer/1639340828

vim lsp 安装配置

作者 nickChen
2020年11月10日 10:26

基于 Language Server Protocol 通用协议作为支撑,结合各语言的 server 支持,以 vim 作为 client,完成在 vim 上进行编程语言开发。

vim-lsp 方案

使用插件 vim-lsp,结合 vim-lsp-settings 作为实现方案。

" 使用vim-plug 插件管理工具安装,使用指南 https://vimjc.com/vim-plug.htmlPlug 'prabirshrestha/vim-lsp'Plug 'mattn/vim-lsp-settings'

git 使用手册

作者 nickChen
2020年8月1日 10:57

[TOC]

git log 使用

git rebase

---A---B---C---D(master)        \         \---E'---F' (feat)j

当前开发在 feat 分支,需要合并 master 代码,使用:

➜ git rebase -i master

合并的时候处理好冲突,合并结束后:

            (master)---A---B---C---D---E'---F'(feat)

此时如果需要将 master 更新到 feat 处,可以使用:

➜ git checkout master➜ git merge --ff feat

执行完成后:

---A---B---C---D---E'---F'(feat)                         \                          \(master)

删除 feat 分支可以使用:

➜ git branch -d feat

此时分支链路为:

---A---B---C---D---E---F(master)

精简 log 打印

git log --online# 01e21e1 (HEAD -> master, origin/master, origin/HEAD) !2 Feature:add lambda support# 55118f9 Feature:add lambda support# 96c6a84 !1 Feat:clean code fragement Merge pull request !1 from 寒沧丶/clean# d52d730 clean:clean code fragment# 0b2ab33 update .gitignore.# d682d2b update README.md.

查询时间范围内的 log 信息

# 只显示2020-08-01到2020-08-08日的提交 git log --after="2020-08-01" --before="2020-08-08"# 显示昨天之后的提交git log --after="yesterday"# 显示这一星期的提交git log --after="1 week ago"git log --after="10 day ago"git log --after="1 month ago"

在log 中展示变更

git log -p# commit 679890d2e3cd7cb53ca586cea72ad1d5abb472e5# Author: tianyaleixiaowu <272551766@qq.com># Date:   Mon May 11 21:55:34 2020 +0800# #     update QuickStart.md.# # diff --git a/QuickStart.md b/QuickStart.md# index 01c8cc9..0bbc812 100644# --- a/QuickStart.md# +++ b/QuickStart.md# @@ -26,7 +26,7 @@#          <dependency>#             <groupId>com.gitee.jd-platform-opensource</groupId>#             <artifactId>asyncTool</artifactId># -           <version>V1.2-SNAPSHOT</version># +           <version>V1.3-SNAPSHOT</version>^M#         </dependency>

*根据提交者过滤 log *

git log --author="nickChen"

根据提交信息检索 log

git log --grep="README"# -i 忽略大小写git log -i --grep="README"# 正则搜索包含 README 或 changelog 的提交信息git log -i --grep="README\|changelog"

查看某个文件的变更log

# 查看这几个文件的提交记录,并打印diffgit log -p README.md changelog# 上述情况下,查询包含 fix 信息的提交记录git log -i --grep="fix" -p README.md changelog

查看文件内容的变更 log

# 查看提交变更中包含 void begin(); 所有 commit log,并打印 log 变更内容# 根据文件内容的提交来查询变更,比较方便定位代码中的一些变化git log -i -S"void begin();" -p

查看 merge commit log

git log --merges

查看两个分支之间的diff

# 查看在 develop 基础上相较于 master 多了哪些 commitgit log master..develop

自定义 log 的输出格式

git log --pretty=format:"%Cred%an - %ar%n %Cblue %h -%Cgreen %s %n"

具体的格式化方式可以参看文档

程序员修炼之道(The Pragmatic Programmer)

作者 nickChen
2020年7月19日 11:20

[TOC]

从小工到专家(From Journeyman to Master)

1> 我的源码让猫吃了
对自己的业务负责,在突发状况下能够提供解决方案而不是说“我的源码让猫吃了”。

2> 软件的熵
软件的发展必然会带来更多的无序,在这个过程中要避免“破窗子”出现时,容忍他的存在。要及时修理“破窗子”,反之更多的“破窗子”也就不会在意了,这将加速软件的衰败。(破窗子:低劣的设计、错误决策、糟糕代码…)

3> 石头汤与青蛙
石头汤:做变化的催化剂——一个项目的启动需要由开头的基石,基石的成功会引起各方的加入共同完成这个项目,做这个基石以促成项目更快更好发展。
青蛙:记住大背景——在项目过程中要牢牢盯住大背景,要持续关注周围发生的事情,而不仅仅是当前所在做的事情。避免成为温水煮青蛙。

4> 足够好的软件
*> *

Redis 实现 (待补全)

作者 nickChen
2019年3月27日 14:56

Redis 设计与实现

Redis 的 String 设计

redis 对于 String 类型有自己的实现, 这个实现简称 SDS (simple dynamic string).

SDS 的优势:
- 安全性、效率、功能方面的需求
O(1)的获取字符串长度的效率,根据len字段
杜绝缓冲区溢出、根据free字段判断是否扩容

- 内存预分配:        len小于1M,修改之后的len的长度 = free长度        大于1M,直接free 1M- 惰性释放:        不返还 free 的区域= = 以便下次使用- 二进制安全:        \0 作为分隔符时,因为有len判断字符串长度,不会出现问题    兼容部分C字符串函数

Redis 的链表设计

链表:用于列表键、pub/sub、慢查询、监视器
- 双端:获取前后节点复杂度都O(1)

- 无环:前后NULL节点- 带head tail- 表长len- 多态 void* 保存节点值

Redis 的字典实现

字典:SET 和 HSET
- 使用哈希表错位底层实现,是redis 是数据库的存储方式
- 使用dictht 作为哈希表实现,封装成 dict,内有长度为2的dictht数组,
用来做扩容,标记位为-1时表示不在rehash
dict {
dictht[2]
dictType 实现多态的函数,复制对比删除等
rehashindex -1表示不在rehash

扩展 ht[1]大小为 第一次大于ht[0].used*2 的 2^n
收缩 。。。。。。。。。大于ht[0].used 的 2^n
- 渐进式哈希:每次插入删除查找更新都rehash一次

Redis 的跳表实现

跳表:
- 实现有序集数据
- 随机化数据结构、它的查找添加删除都可以在对数期望时间下完成

Redis 的压缩列表实现

压缩列表:
- ziplist结构:
header - entries - end
bytes-tail-len
- entry结构:
prelen - encoding - len - content

Redis 的引用对象实现

redisObject 结构:
- key 对应的是一个 redisObject 结构,用来多态操作
redisObject {
type.
encoding. type 和 encoding 定位底层数据结构
*ptr
}
引用计数回收

  • 哈希表默认由压缩列表实现:当某个key/value长度大于64

                      或者 entries 的个数大于 512                   会转变成字典实现
  • 列表默认也由压缩列表实现:同上;会转变为双端链表

  • 阻塞:维持一个server[i]->block_keys列表,key为造成阻塞的key,value为客户端链接
    readyList 用来保存即将离开block队列

  • 事务:WATCH MULTI DISCARD EXEC

  • 慢日志查询:设置时间和保存数量

       SLOW GET 查看

Screen 使用指南

作者 nickChen
2018年5月9日 21:48

Screen 使用流程

参考资料:

Screen 是管理会话的一个工具,能够持有多个会话并维持,方便切换,真切的提高工作效率。

简单使用

1> 创建会话

直接键入 screen 就可以开启一个新的会话窗口:

➜ screen# 执行之后立刻进入一个新的会话

也可以在后面跟指令,可以直接在新会话中执行这条指令:

➜ screen vim new.txt# 这里如果使用 :q 推出 vim 的话,也会直接退出当前的会话

screen 管理的会话中想要创建新的会话可以使用 C-a c

这里的 C-a 指的是 Ctrl + a,是 screen 的命令字符(command character)。

2> 管理会话

Q: 创建了多个 screen 会话之后,如何实现会话之间的跳转?

A: 使用 C-a 0..9. 在 0 ~ 9 会话之间切换, C-a n 切换到下一个会话,C-a p 切换到上一个会话。

快速回到上一个会话:使用了 C-a 0 进入了会话 0,再使用会话 C-a 3 进入了会话 3,可以直接使用 C-a C-a 返回上一个会话——即会话 0。

Q: 如何看当前新建了哪些会话?

A: 使用 C-a w 显示所有的窗口列表。

➜  0- /  1* /  2 /  3 /  4 /# * 号表示的是当前会话

Q: 会话都是 0~9 不方便区分会话任务,如何处理?

A: 使用 C-a A 给会话起别名,这样在使用 C-a w 指令后可以看到所有会话的别名用来区分任务。

➜ 0 /vim  1 /vim1  2- /upload  3* /do  4 /# 可以看到 0 和 1 在执行 vim,2 在执行一个上传任务,3 在做些别的事儿,4 还没有起别名

3> 挂起|重连会话

可以使用 C-a d 用来挂起 screen 会话,这样就可以回到原来的终端了:

[detached] # 挂起 screen 会话之后打印的 echo➜  / # 已经回到原来的终端

使用 screen -ls 可以看到这个被挂起的会话:

➜  / screen -lsThere is a screen on:    3187.ttys002.lzdeMBP    (Detached) # 这就是被挂起的会话1 Socket in /var/folders/mh/zj230g0x2996t8qvszyj3qy80000gn/T/.screen.

可以使用 screen -r <session pid> 重连到该会话:

➜  / screen -r 3187

这样就能重新进入 screen 会话了。

创建了多个会话之后,有的会话已经完成了它的职责,这时候需要关闭它了。

使用 C-a k 可以关闭当前会话。

其他常用指令:

-c file使用配置文件file,而不使用默认的$HOME/.screenrc
-d/-D [pid.tty.host]不开启新的screen会话,而是断开其他正在运行的screen会话
-h num指定历史回滚缓冲区大小为num行
-list/-ls列出现有screen会话,格式为pid.tty.host
-d -m启动一个开始就处于断开模式的会话
-r sessionowner/ [pid.tty.host]重新连接一个断开的会话。多用户模式下连接到其他用户screen会话需要指定sessionowner,需要setuid-root权限
-S sessionname创建screen会话时为会话指定一个名字
-v显示screen版本信息
-wipe [match]同-list,但删掉那些无法连接的会话

-d –m 选项是一对很有意思的搭档。他们启动一个开始就处于断开模式的会话。

你可以在随后需要的时候连接上该会话。有时候这是一个很有用的功能,比如我们观察日志文件。

该选项一个更常用的搭配是:-dmS sessionname 启动一个初始状态断开的 screen 会话:

➜ / screen -dmS xxxLog tail -n 10 -f zk.log# 这里就开启了一个观察日志的会话# 下面只需要执行程序,然后重连到这个会话就好了➜ / screen -r xxxLog# 连接到打印日志的会话

4> 分屏使用

充分利用大屏优势:

  • 使用 C-a S 可以上下分屏,C-a | 可以水平分屏(这个要高版本才有了)
  • 使用 C-a Tab 可以切换到另一个屏幕
  • 使用 C-a X 可以取消当前分屏

管理 Screen

同大多数UNIX程序一样,GNU Screen提供了丰富强大的定制功能。你可以在Screen的默认两级配置文件 /etc/screenrc$HOME/.screenrc 中指定更多,例如设定screen选项,定制绑定键,设定screen会话自启动窗口,启用多用户模式,定制用户访问权限控制等等。

配置 ~/.screenrc

# Set default encoding using utf8defutf8 on## 解决中文乱码,这个要按需配置defencoding utf8encoding gbk utf8#兼容shell 使得.bashrc .profile /etc/profile等里面的别名等设置生效shell -$SHELL#set the startup messagestartup_message offterm linux## 解决无法滚动termcapinfo xterm|xterms|xs ti@:te=\E[2J# 屏幕缓冲区行数defscrollback 10000# 下标签设置hardstatus oncaption always "%{= kw}%-w%{= kG}%{+b}[%n %t]%{-b}%{= kw}%+w %=%d %M %0c %{g}%H%{-}"#关闭闪屏vbell off

TODO…

使用ZK实现 Cache 通过 Curator

作者 nickChen
2018年4月14日 17:03

Curator Framework 深入了解

本文受到 colobu 前辈文章的指引,深入了解 Curator Framework 的工作流程,十分感谢 colobu 前辈的博文给予的启发和指导。

ZooKeeper Cache 实现

利用 ZooKeeper 在集群的节点上缓存数据。示例代码

Path Cache

Path Cache 使用 ZK 的节点作为 KV 存储系统,在实现上涉及的类:

  • PathChildrenCache
  • PathChildrenCacheEvent
  • PathChildrenCacheListener
  • ChildData

PathChildrenCache 是主要类,他的构造方法是

 public PathChildrenCache(CuratorFramework client,                              String path,                             boolean cacheData /*是否缓存node,会缓存在一个 ConcurrentHashMap内*/)

比较奇特的是,设计上PathChildrenCache 只负责获取数据,也就是只有 listget 的操作,并没有 setremove 操作,需要新增数据之类的都是统一通过 CuratorFramework 构造出的 client 去做对应操作(任何对 zk 节点完成增删的操作都可)。PathChildrenCache 通过对构造函数中填入的 PATH 路径进行监听,这里有两个 Watcher,childrenWatcher 负责监听节点的增加,dataWatcher负责监听节点数据的改动和节点的删除。
构造好一个 PathChildrenCache 后需要 start() 后才能正常使用,调用 close()来结束使用。start() 方法中也可以传入 StartMode,用来为初始的 cache 设置暖场方式(warm):

  1. NORMAL: 初始时为空。
  2. BUILD_INITIAL_CACHE: 在这个方法返回之前调用rebuild(),此方法会将 ZK 的节点 kv 存到本地缓存(ConcurrentHashMap)内。
  3. POST_INITIALIZED_EVENT: 当Cache初始化数据后发送一个PathChildrenCacheEvent.Type#INITIALIZED 事件

获取当前的 Cache 值可以使用如下方法:

// PathChildrenCache.class// 获取所有的缓存数据public List<ChildData> getCurrentData() {        return ImmutableList.copyOf(Sets.<ChildData>newTreeSet(currentData/*这就是本地缓存 concurrentHashMap*/.values()));}// 获取指定 PATH 下的缓存数据(实际就是用 key)public ChildData getCurrentData(String fullPath) {        return currentData/*这就是本地缓存 concurrentHashMap*/.get(fullPath);}

可以增加 listener 监听缓存变化:

/*这里的 Listener 就是 PathChildrenCacheListener 的实例,据此新建自己的监听器。*/cache/*PathChildrenCache 的实例*/.getListenable().addListener(listener);

Node Cache

Node Cache 顾名思义就是只对一个 Node 节点做监控,涉及到下面的三个类:

  • NodeCache
  • NodeCacheListener
  • ChildData

Node Cache 在更新数据的时候并不是同步的,也就意味着并发修改数据返回意料之外的结果。使用这个缓存的时候需要自己多加注意。具体的使用方法同 Path Cache,只是 Node Cache 的 getCurrentData() 只会返回一个 ChildData 了。

Tree Node

Tree Node 既可以监控节点的状态,也监控节点的子节点的状态。涉及到下面四个类:

  • TreeCache
  • TreeCacheListener
  • TreeCacheEvent
  • ChildData

TreeCache 使用 Builder 模式来构造:

// TreeCache.Builder.classprivate Builder(CuratorFramework client, String path){    this.client = checkNotNull(client);    this.path = validatePath(path);}// 构造出来一个 builder 后可以配置private boolean cacheData = true;private boolean dataIsCompressed = false;private ExecutorService executorService = null;private int maxDepth = Integer.MAX_VALUE;private boolean createParentNodes = false;

TreeCache 也可以使用 getCurrentChildren(String path) 方法获取 path 下一级的所有的 kv 对。

❌
❌