Soul is an open source tool to easily create and deploy services in golang.
Let's get your service up and running in few minutes.
Required packages for this example:
import (
"github.com/entropyx/soul"
"github.com/entropyx/soul/middlewares" //built-in middlewares
"github.com/entropyx/soulutils" //internal helper functions
)
Initialize and name your new service.
service := soul.New("myservice")
The default router uses AMQP as engine and includes important middlewares:
r := soulutils.DefaultRouter(service)
Create a group named after the main resource:
users := r.Group("users")
And any subgroup you need:
configuration := users.Group("configuration")
Generate a consumer that listens to the routing key and executes a function for every incoming message:
users.Listen("update", func(c *context.Context) {
...
})
Once the consumer is ready, call Service.Run. Everything below this function will run after the service is shutdown.
service.Run()
fmt.Println("Bye!")
Start the consumer by executing the listen
command:
myservice listen users.update
A context.Handler receives a context which contains relevant information about the current request and the required methods to handle it.
func UpdateUsers(c *context.Context) {
...
}
To properly get the request body use Bind.
update := &protos.UpdateUsers{}
if err := c.Bind(update); err != nil {
c.Error = err
return
}
As you could notice any reached error should be set to c.Error, so it can be handled by pending middlewares. Don't forget to stop the execution of the current function in order to avoid unexpected behaviors.
If everything is okay, call soulutils.WriteResponse
to write a proto to a protos.Response
. This won't stop the current function.
func UpdateUsers(c *context.Context) {
user := &protos.Users{}
soulutils.WriteResponse(c, user)
}
For legacy response messages use context.Proto.
c.Proto(user)
Services are intended to communicate across the network which can lead to unexpected behaviors. To troubleshoot services performance, propagate an Open Tracing context.
func UpdateUsers(c *context.Context) {
usersService := services.NewUsers(services.OptionContext(c))
}
Get the log entry from context.Log to print a message:
c.Log().Info("Hello")
c.Log().Error("Something bad just happened")
You can assign the entry to a simpler variable:
log := c.Log()
log.Info("Hello")
The default entry includes all the fields that were previously configured by the middlewares. If you need to add some extra fields, pass the result of calling entry.WithField or entry.WithFields over the default entry to context.SetLog . Passing a new entry will just rewrite the current one.
// Use WithField if you need a single field.
c.SetLog(c.Log().WithField("organization_id", organization.ID))
// Use WithFields if you need more than a field.
c.SetLog(c.Log().WithFields(logrus.Fields{
"user_id":user.ID,
"account_id":account.ID
}))
There are some situation, like in commands and cronjobs, that will force you to work with a standard context.
Set a log entry to a context with soulutils.WithLog
.
import (
"context"
"github.com/entropyx/soulutils"
"github.com/sirupsen/logrus"
)
func handler(c context.Context) {
entry := logrus.NewEntry(logrus.New())
c = soulutils.WithLog(c,entry)
}
This sets the value in the context using a custom key, which is only accessible through soulutils.Log
.
log := soulutils.Log(c)
log.Info("Hello")
By default, your consumers will report their own status. If your consumer is successfully connected, the http status code will be 200 and the body will include a health check status list:
Your service can report the status of database connections or external services availability.
curl localhost:8081/health_check
HTTP/1.1 200 OK
{"test.a 0":true}
You can add custom health checks:
service.HealthCheck("database", func() bool {
if err := db.Ping(); err != nil {
return false
}
return true
})
{"database":true,"test.a 0":true}
If any of your health checks fails, the status code will be 500.
HTTP/1.1 500 Internal Server Error
You can safely shutdown your service using quit command. Your consumer will stop receiving messages and disconnect itself once the current work is done.
myservice quit
Testing your handlers is a unavoidable task you must perform.
Required packages for the examples:
import (
"github.com/entropyx/anyutils"
"github.com/entropyx/protos"
"github.com/entropyx/soul/context"
"github.com/entropyx/soulutils"
. "github.com/smartystreets/goconvey/convey"
)
soulutils.TestContext
simulates a request by sending message
through the default middlewares
and handlers
.
request := &protos.Users{Email: "[email protected]"}
c, mock := soulutils.TestContext(request, UpdateUsers)
The returned values are two:
c *context.Context: This is the context passed through the handlers, which may contain an error
(c.Error) and values (c.Get).
mock *context.MockContext: The mocked engine context, which contains a response (*context.R).
Use anyutils.UnmarshalResponse
to get the response body from mock
:
response := &protos.Users{}
err := anyutils.UnmarshalResponse(mock.Response.Body, response)
Now you can test that your handler works as expected:
Convey("The context error should be nil", func() {
So(c.Error, ShouldBeNil)
})
Convey("The response should be valid", func() {
So(response.Users[0].Email, ShouldEqual, "[email protected]")
})
Your service is powered by cobra, a tool for creating CLI applications. You can add new commands with Service.Command. This implementation is easy and flexible enough but we need to add some custom code to each of the new commands. Use soulutils.Command
to add extra configuration to a context.+
import (
"context"
"github.com/entropyx/soul"
"github.com/spf13/cobra"
)
var cmdListUsers = &cobra.Command{
Use: "list",
Short: "List the available users",
Run: soulutils.Command(ListUsers),
}
func main(){
service := soul.New("moises")
service.Command(cmdListUsers)
}
It generates a func(*cobra.Command, []string)
that is used as a cobra.Command run function. You can get the function parameters with soulutils.CmdValues
:
func ListUsers(c context.Context){
cmd,args := soulutils.CmdValues(c)
...
}
log := soulutils.Log(c)
log.Info("Hello")
spanCtx := soulutils.SpanContext(c)
service := services.New(services.OptionSpanContext(spanCtx))
organizationsService := services.NewOrganizations(service)
soulutils.Command
API will probably change, since a hook implementation may be the best idiomatic alternative. Stay tunned, please.
A cron job is a task that runs periodically at fixed intervals. You can schedule your own cronjob with service.Cronjob. Although, like in the commands, you need to add some custom code.
Your final cron job function must receive a context used to share some values. You will see how to get those values in short.
func UpdateUsers(c context.Context){
...
}
Now, pass the name of your cron job and your new function wrapped with soulutils.CronJob
to service.Cronjob.
service.CronJob("update-users", soulutils.CronJob(UpdateUsers))
Run your cron job with cronjob
command. Define the interval with -s
. The schedule format is explained in cron documentation.
myservice cronjob -s @hourly update-users
log := soulutils.Log(c)
log.Info("Hello")
spanCtx := soulutils.SpanContext(c)
service := services.New(services.OptionSpanContext(spanCtx))
organizationsService := services.NewOrganizations(service)
Your service struct should embed a pointer to a Service, which includes methods to publish.
type Users struct {
*Service
}
Create a function that returns your preconfigured service struct. This function should first initialize a Service by calling New and set it to your struct.
func NewUsers(options ...Option) *Users {
s := New(options...)
return &Users{s}
}
An Option is a function that receives the *Service you initialize. It modifies the Service state.
services.NewUsers(services.OptionSpanContext(spanCtx))
Create your action method. unmarshal returns the response error.
func (u *Users) Retrieve(selector *protos.Selector) ([]*protos.User,error) {
var body []byte
users := &protos.Users{}
err := u.publish(&publishingConfig{
routingKey: "users.retrieve",
timeout: u.Timeout,
out: selector
}, func(delivery *rabbitgo.Delivery) {
body = delivery.Body
})
if err != nil {
return nil, err
}
if err := unmarshal(body, users); err != nil {
return nil, err
}
return users.Items, nil
}