DEV Community

Cover image for Building a Rule Engine in Go Using Govaluate
Leapcell
Leapcell

Posted on

3 2 1 1 2

Building a Rule Engine in Go Using Govaluate

Image description

Leapcell: The Best of Serverless Web Hosting

Introduction

In 2024, I used govaluate to write a rule engine. Its advantage lies in endowing Go with the capabilities of a dynamic language, enabling it to perform some calculation operations and obtain results. This allows you to achieve the functionality without writing the corresponding code; instead, you just need to configure a string. It is very suitable for building a rule engine.

Quick Start

First, install it:

$ go get github.com/Knetic/govaluate
Enter fullscreen mode Exit fullscreen mode

Then, use it:

package main

import (
  "fmt"
  "log"

  "github.com/Knetic/govaluate"
)

func main() {
  expr, err := govaluate.NewEvaluableExpression("5 > 0")
  if err != nil {
    log.Fatal("syntax error:", err)
  }

  result, err := expr.Evaluate(nil)
  if err != nil {
    log.Fatal("evaluate error:", err)
  }

  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

There are only two steps to calculate an expression using govaluate:

  1. Call NewEvaluableExpression() to convert the expression into an expression object.
  2. Call the Evaluate method of the expression object, pass in the parameters, and return the value of the expression.

The above example demonstrates a simple calculation. Using govaluate to calculate the value of 5 > 0, this expression does not require parameters, so a nil value is passed to the Evaluate() method. Of course, this example is not very practical. Obviously, it is more convenient to calculate 5 > 0 directly in the code. However, in some cases, we may not know all the information about the expression that needs to be calculated, and we may not even know the structure of the expression. This is where the role of govaluate becomes prominent.

Parameters

govaluate supports the use of parameters in expressions. When calling the Evaluate() method of the expression object, parameters can be passed in for calculation through a map[string]interface{} type. Among them, the key of the map is the parameter name, and the value is the parameter value. For example:

func main() {
  expr, _ := govaluate.NewEvaluableExpression("foo > 0")
  parameters := make(map[string]interface{})
  parameters["foo"] = -1
  result, _ := expr.Evaluate(parameters)
  fmt.Println(result)

  expr, _ = govaluate.NewEvaluableExpression("(leapcell_req_made * leapcell_req_succeeded / 100) >= 90")
  parameters = make(map[string]interface{})
  parameters["leapcell_req_made"] = 100
  parameters["leapcell_req_succeeded"] = 80
  result, _ = expr.Evaluate(parameters)
  fmt.Println(result)

  expr, _ = govaluate.NewEvaluableExpression("(mem_used / total_mem) * 100")
  parameters = make(map[string]interface{})
  parameters["total_mem"] = 1024
  parameters["mem_used"] = 512
  result, _ = expr.Evaluate(parameters)
  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

In the first expression, we want to calculate the result of foo > 0. When passing in the parameter, we set foo to -1, and the final output is false.
In the second expression, we want to calculate the value of (leapcell_req_made * leapcell_req_succeeded / 100) >= 90. In the parameters, we set leapcell_req_made to 100 and leapcell_req_succeeded to 80, and the result is true.
The above two expressions both return boolean results, and the third expression returns a floating-point number. (mem_used / total_mem) * 100 returns the memory usage percentage according to the passed-in total memory total_mem and the current used memory mem_used, and the result is 50.

Naming

Using govaluate is different from directly writing Go code. In Go code, identifiers cannot contain symbols such as -, +, $, etc. However, govaluate can use these symbols through escaping, and there are two ways of escaping:

  1. Wrap the name with [ and ], for example, [leapcell_resp-time].
  2. Use \ to escape the next character immediately following it.

For example:

func main() {
  expr, _ := govaluate.NewEvaluableExpression("[leapcell_resp-time] < 100")
  parameters := make(map[string]interface{})
  parameters["leapcell_resp-time"] = 80
  result, _ := expr.Evaluate(parameters)
  fmt.Println(result)

  expr, _ = govaluate.NewEvaluableExpression("leapcell_resp\\-time < 100")
  parameters = make(map[string]interface{})
  parameters["leapcell_resp-time"] = 80
  result, _ = expr.Evaluate(parameters)
  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

It should be noted that since the \ itself needs to be escaped in a string, \\ should be used in the second expression. Or you can use

`leapcell_resp\-time` < 100
Enter fullscreen mode Exit fullscreen mode

"Compile" Once and Run Multiple Times

Using expressions with parameters, we can achieve "compiling" an expression once and running it multiple times. Just use the expression object returned by the compilation and call its Evaluate() method multiple times:

func main() {
  expr, _ := govaluate.NewEvaluableExpression("a + b")
  parameters := make(map[string]interface{})
  parameters["a"] = 1
  parameters["b"] = 2
  result, _ := expr.Evaluate(parameters)
  fmt.Println(result)

  parameters = make(map[string]interface{})
  parameters["a"] = 10
  parameters["b"] = 20
  result, _ = expr.Evaluate(parameters)
  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

When running for the first time, pass in the parameters a = 1 and b = 2, and the result is 3; when running for the second time, pass in the parameters a = 10 and b = 20, and the result is 30.

Functions

If it can only perform regular arithmetic and logical operations, the functionality of govaluate will be greatly reduced. govaluate provides the function of custom functions. All custom functions need to be defined first and stored in a map[string]govaluate.ExpressionFunction variable, and then call govaluate.NewEvaluableExpressionWithFunctions() to generate an expression, and these functions can be used in this expression. The type of a custom function is func (args ...interface{}) (interface{}, error). If the function returns an error, the evaluation of this expression will also return an error.

func main() {
  functions := map[string]govaluate.ExpressionFunction{
    "strlen": func(args ...interface{}) (interface{}, error) {
      length := len(args[0].(string))
      return length, nil
    },
  }

  exprString := "strlen('teststring')"
  expr, _ := govaluate.NewEvaluableExpressionWithFunctions(exprString, functions)
  result, _ := expr.Evaluate(nil)
  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

In the above example, we defined a function strlen to calculate the string length of the first parameter. The expression strlen('teststring') calls the strlen function to return the length of the string teststring.
Functions can accept any number of parameters and can handle the problem of nested function calls. Therefore, complex expressions like the following can be written:

sqrt(x1 ** y1, x2 ** y2)
max(someValue, abs(anotherValue), 10 * lastValue)
Enter fullscreen mode Exit fullscreen mode

Accessors

In the Go language, accessors are used to access fields in a struct through the . operation. If there is a struct type among the passed-in parameters, govaluate also supports using . to access its internal fields or call their methods:

type User struct {
  FirstName string
  LastName  string
  Age       int
}

func (u User) Fullname() string {
  return u.FirstName + " " + u.LastName
}

func main() {
  u := User{FirstName: "li", LastName: "dajun", Age: 18}
  parameters := make(map[string]interface{})
  parameters["u"] = u

  expr, _ := govaluate.NewEvaluableExpression("u.Fullname()")
  result, _ := expr.Evaluate(parameters)
  fmt.Println("user", result)

  expr, _ = govaluate.NewEvaluableExpression("u.Age > 18")
  result, _ = expr.Evaluate(parameters)
  fmt.Println("age > 18?", result)
}
Enter fullscreen mode Exit fullscreen mode

In the above code, we defined a User struct and wrote a Fullname() method for it. In the first expression, we call u.Fullname() to return the full name, and in the second expression, we compare whether the age is greater than 18.
It should be noted that we cannot use the foo.SomeMap['key'] way to access the value of a map. Since accessors involve a lot of reflection, they are usually about 4 times slower than directly using parameters. If you can use the form of parameters, try to use parameters. In the above example, we can directly call u.Fullname() and pass the result as a parameter to the expression evaluation. Complex calculations can be solved through custom functions. We can also implement the govaluate.Parameter interface. For unknown parameters used in the expression, govaluate will automatically call its Get() method to obtain them:

// src/github.com/Knetic/govaluate/parameters.go
type Parameters interface {
  Get(name string) (interface{}, error)
}
Enter fullscreen mode Exit fullscreen mode

For example, we can make User implement the Parameter interface:

type User struct {
  FirstName string
  LastName  string
  Age       int
}

func (u User) Get(name string) (interface{}, error) {
  if name == "FullName" {
    return u.FirstName + " " + u.LastName, nil
  }

  return nil, errors.New("unsupported field " + name)
}

func main() {
  u := User{FirstName: "li", LastName: "dajun", Age: 18}
  expr, _ := govaluate.NewEvaluableExpression("FullName")
  result, _ := expr.Eval(u)
  fmt.Println("user", result)
}
Enter fullscreen mode Exit fullscreen mode

The expression object actually has two methods. One is the Evaluate() method we used before, which accepts a map[string]interface{} parameter. The other is the Eval() method we used in this example, which accepts a Parameter interface. In fact, the Evaluate() implementation internally also calls the Eval() method:

// src/github.com/Knetic/govaluate/EvaluableExpression.go
func (this EvaluableExpression) Evaluate(parameters map[string]interface{}) (interface{}, error) {
  if parameters == nil {
    return this.Eval(nil)
  }
  return this.Eval(MapParameters(parameters))
}
Enter fullscreen mode Exit fullscreen mode

When evaluating an expression, the Get() method of Parameter needs to be called to obtain unknown parameters. In the above example, we can directly use FullName to call the u.Get() method to return the full name.

Supported Operations and Types

The operations and types supported by govaluate are different from those in the Go language. On the one hand, the types and operations in govaluate are not as rich as those in Go; on the other hand, govaluate has also extended some operations.

Arithmetic, Comparison, and Logical Operations

  • + - / * & | ^ ** % >> <<: Addition, subtraction, multiplication, division, bitwise AND, bitwise OR, XOR, exponentiation, modulus, left shift, and right shift.
  • > >= < <= == != =~ !~: =~ is for regular expression matching, and !~ is for regular expression non-matching.
  • || &&: Logical OR and logical AND.

Constants

  • Numeric constants. In govaluate, numbers are all treated as 64-bit floating-point numbers.
  • String constants. Note that in govaluate, strings are enclosed in single quotes '.
  • Date and time constants. The format is the same as that of strings. govaluate will try to automatically parse whether the string is a date, and only supports limited formats such as RFC3339 and ISO8601.
  • Boolean constants: true, false.

Others

  • Parentheses can change the calculation precedence.
  • Arrays are defined in (), and each element is separated by ,. It can support any element type, such as (1, 2, 'foo'). In fact, in govaluate, arrays are represented by []interface{}.
  • Ternary operator: ? :.

In the following code, govaluate will first convert 2025-03-02 and 2025-03-01 23:59:59 to the time.Time type, and then compare their magnitudes:

func main() {
  expr, _ := govaluate.NewEvaluableExpression("'2025-03-02' > '2025-03-01 23:59:59'")
  result, _ := expr.Evaluate(nil)
  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

In the above examples, we deliberately ignored error handling. In fact, govaluate may generate errors in both operations of creating an expression object and evaluating an expression. When generating an expression object, if the expression has a syntax error, an error will be returned. When evaluating an expression, if the passed-in parameters are illegal, or some parameters are missing, or an attempt is made to access a non-existent field in a struct, an error will be reported.

func main() {
  exprString := `>>>`
  expr, err := govaluate.NewEvaluableExpression(exprString)
  if err != nil {
    log.Fatal("syntax error:", err)
  }
  result, err := expr.Evaluate(nil)
  if err != nil {
    log.Fatal("evaluate error:", err)
  }
  fmt.Println(result)
}
Enter fullscreen mode Exit fullscreen mode

We can modify the expression string in turn to verify various errors. First, it is >>>:

2025/03/19 00:00:00 syntax error:Invalid token: '>>>'
Enter fullscreen mode Exit fullscreen mode

Then we modify it to foo > 0, but we do not pass in the parameter foo, and the execution fails:

2025/03/19 00:00:00 evaluate error:No parameter 'foo' found.
Enter fullscreen mode Exit fullscreen mode

Other errors can be verified by yourself.

Conclusion

Although the operations and types supported by govaluate are limited, it can still implement some interesting functions. For example, you can write a web service that allows users to write their own expressions, set parameters, and let the server calculate the results.

Leapcell: The Best of Serverless Web Hosting

Finally, I would like to recommend the most suitable platform for deploying Go services: Leapcell

Image description

🚀 Build with Your Favorite Language

Develop effortlessly in JavaScript, Python, Go, or Rust.

🌍 Deploy Unlimited Projects for Free

Only pay for what you use—no requests, no charges.

⚡ Pay-as-You-Go, No Hidden Costs

No idle fees, just seamless scalability.

Image description

📖 Explore Our Documentation

🔹 Follow us on Twitter: @LeapcellHQ

Top comments (0)