Millet Porridge

English version of https://corvo.myseu.cn

0%

Programming with Groovy: Tips and Insights on Flow Control Techniques

In my recent work, I’ve written a lot of Groovy code. I feel like I’ve accumulated some experiences and insights worth sharing. Therefore, I decided to write this blog post, hoping to help developers who are currently or soon to be using Groovy.

I hope my readers have some experience in writing Jenkins pipelines or have used the CI/CD features provided by GitLab, GitHub, or Azure.

In this blog post, I mainly introduce some solutions for controlling the execution flow and provide some code snippets that I have used in practice.

Note: The Scripted Pipeline is used in this article.

Please refer to GitHub BlogCode

Code Debug

I highly recommend using Groovy Playground for debugging Groovy code. Although the syntax structure may not be entirely consistent, the ease of execution and result viewing will greatly increase development efficiency.”

https://onecompiler.com/groovy

1711859779592.png

Control Flow

In Groovy, the flexibility of control flow is one of its powerful features. Sequential logic is the most common, and I don’t intend to spend time on it. Instead, I will focus on concurrency and retry aspects.

For these two types of control flow, I strongly recommend using Blue Ocean for its visually appealing presentation.

Parallel

By using the ‘parallel’ keyword, I present two implementation methods.

1711861460417.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
stage('run-parallel-branches') {
parallel(
a: {
echo "This is branch a"
},
b: {
echo "This is branch b"
}
)
}

// I personally prefer this one, as it allows for easy use of 'for' loops and 'if' statements to control concurrency logic.
stage('looper-parallel-branches') {
def looper = [:]
for (int i = 0; i < 10; i++) {
looper["${i}"] = {
echo "This is branch ${i}"
}
}
parallel looper
}

Retry

The retry logic is suitable for ensuring that a task is executed correctly without rerunning the entire pipeline. Jenkins also provides a retry feature.

In the following code, an error occurs due to the generation of an even number randomly, and we can directly retry using the retry feature.

1711861660821.png

Additionally, the execution records of each retry will also be stored in the pipeline logs

1711861812746.png

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
stage('retry') {
testJob = { ->
// generate number between 40 and 99
def num = Math.abs( new Random().nextInt() % (99 - 40) ) + 40
if(num % 2 == 0) {
throw new Exception("Even number")
}
}

retryFunc = { job ->
waitUntil {
try {
job()
true
} catch (error) {
println error
input 'Retry the job ?'
false
}
}
}

def looper = [:]
for (int i = 0; i < 5; i++) {
looper["${i}"] = {
retryFunc(testJob)
}
}
parallel looper
}

Design Pattern

The following content is suitable for those who want to optimize existing complex pipeline logic. Readers who haven’t written pipelines before can skip this part.

Dependency Injection

In terms of dependency injection here, what I really mean to emphasize is inversion of control, where the control logic is abstracted away. Remember the concurrency execution methods I mentioned earlier?

If our business involves many processes, such as executing command 1 on all environments in one step, followed by executing command 2 on all environments in the next step, we might end

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stage('action 1') {
def looper = [:]
workNodes.each { node ->
looper["Action1 for ${node}"] = {
println "action1 on $node"
}
}
parallel looper
}

stage('action 2') {
def looper = [:]
workNodes.each { node ->
looper["Action2 for ${node}"] = {
println "action2 on $node"
}
}
parallel looper
}

Does it feel like the same logic is written twice? What if this operation is repeated many times? It will lead to a large amount of unnecessary looping in the code, which is irrelevant to the business. Here, I propose an improved solution. Its final effect remains consistent:

1711867353521.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def actionRunner = { msg, nodes, action ->
def looper = [:]
nodes.each { node ->
looper["${msg} -- ${node}"] = {
stage("${msg} -- ${node}") {
action(node)
}
}
}
parallel looper
}
// Our actual business code should only include the following content,
// with very good readability and flexibility.
// However, the premise is that you understand the control logic above.
actionRunner("action1", workNodes, { node ->
println "action1 on $node"
})

actionRunner("action2", workNodes, { node ->
println "action2 on $node"
})

Decorator

I used to be a proficient Python programmer, and I found that in Groovy pipelines, there are also some logics that are very suitable for decorators, such as retry logic.

We’ve discussed its implementation in the control flow above, but we actually have a more elegant solution. When you want to add similar functionality to many functions, decorators are a great choice.

The following code can work well in the Groovy Playground but cannot be used in Jenkins pipelines. Here, I provide two approaches for writing decorators. You can choose one:

curry wrapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// curry wrapper
def testWrapper(Closure job) {
def varFunc = { func, Object... args ->
println "args ${args}, ${args.getClass()}, ${args.size()}"
func.call(*args)
}
return varFunc.curry(job)
}

def xx = { a,b,c ->
println "a ${a}"
println "b ${b}"
println "c ${c}"
}
def func = testWrapper(xx)
func("aa", "bb", "cc")

/*
output:
args [aa, bb, cc], class [Ljava.lang.Object;, 3
a aa
b bb
c cc
*/

simple wrapper

This approach is easier to understand, after all, not everyone would consider using function currying.

1
2
3
4
5
6
7
def testWrapper(Closure job) {
def varFunc = { Object... args ->
println "args ${args}, ${args.getClass()}, ${args.size()}"
job.call(*args)
}
return varFunc
}

It’s unfortunate that neither of these approaches can be directly used in Jenkins. They will result in the following error:

1712240532006.png

The core reason is that there is an issue with variable parameters in Jenkins Closure. Here, when we wrote Object… args for variable parameters, it only reads the first parameter as read-only.

Jenkins wrapper

Here’s an alternative solution I propose: using a function wrapped in a wrapper, passing parameters as an array. We’ll fill in the parameters separately when making the actual call.

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
def retryWrapper(Closure job) {
def func = { Object[] args ->
waitUntil {
try {
println "args ${args}, ${args.getClass()}, ${args.size()}"
job.call(*args)
true
} catch (error) {
println error
input 'Retry the job ?'
false
}
}
}
return func
}

testJob = retryWrapper({ arg1, arg2 ->
println("${arg1}, ${arg2}")
// generate number between 40 and 99
def num = Math.abs( new Random().nextInt() % (99 - 40) ) + 40
if(num % 2 == 0) {
throw new Exception("Even number")
}
})

def looper = [:]
for (int i = 0; i < 5; i++) {
looper["${i}"] = {
testJob(["arg1", i])
}
}
parallel looper

1712241220461.png

Conclusion

This blog post mainly aims to summarize the issues I’ve recently encountered and the design patterns I’ve prepared to address them. I hope it can be helpful for friends who use Jenkins. If there are better solutions from experts, please don’t hesitate to share your insights.