Post

Introduction to Workflow Specs

A Workflow Spec or just Workflow defines the core business logic of an application. For example, in a social network application, the workflow defines how users can upload posts, view their timeline feed, follow other users, etc.

In Blueprint, a workflow is implemented without reference to any of the libraries of infrastructure needed to deploy the workflow. A workflow does not need to bind to an RPC library like gRPC or implement a mechanism like retries. Instead, these are integrated into the workflow code later by Blueprint’s Compiler.

While developing an application’s workflow, the philosophy should be to assume nothing about exactly how the application will be deployed. Services might be deployed into different processes running on the same machine; in containers distributed across a cluster; or even, directly combined into a single monolith application.

Project Layout

A Blueprint application will likely comprise several golang modules, primarily for the application’s workflow spec and wiring spec(s). By convention, we recommend placing these modules in sibling directories (e.g. workflow and wiring directories). The Sock Shop application demonstrates this structure and convention.

The workflow subdirectory will contain your workflow implementation. Your workflow module will likely want a dependency on the github.com/blueprint-uservices/blueprint/runtime module.

Later, you may choose to also create a tests module for Workflow Tests and a workload module for a custom Workload Generator, though these are not needed yet.

Workflow Services

A Workflow consists of a number of inter-related Services. A service is akin to a microservice or a class that provides some public methods; other services can call those methods.

Define a service by declaring an interface with some methods:

1
2
3
4
type EchoService interface {
    // Echoes the provided message back to the caller
    Echo(ctx context.Context, message string) (string, error)
}

Implement the service with a struct

1
2
3
4
5
6
7
8
9
type echoServiceImpl struct {}

func NewEchoService(ctx context.Context) (EchoService, error) {
    return &echoServiceImpl{}, nil
}

func (s *echoServiceImpl) Echo(ctx context.Context, message string) (string, error) {
    return message, nil
}

The above is sufficient to compile the EchoService to a process, docker container, etc. and make use of any of the plugins offered by Blueprint.

Rules

Blueprint requires the following from workflow services:

  • A service must be defined by an interface, e.g. EchoService
  • The first argument of all service methods is a context.Context
  • The final return value of all service methods is an error
  • A service constructor must be defined that returns a service instance, e.g. NewEchoService
  • The first argument of a constructor must be a context.Context
  • The return value of a constructor must be the service instance and an error, e.g. (EchoService, error)

A workflow can import and make use of any 3rd party libraries it desires.

Calling other Workflow Services

A service can make calls to other services. To do so, the service needs a reference to those other services.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type MultiEchoer struct {
    // Calls the EchoService n times
    MultiEcho(ctx context.Context, message string, times int) (string, error)
}

type multiEchoerImpl struct {
    echo EchoService
}

func NewMultiEchoer(ctx context.Context, echo EchoService) (MultiEchoer, error) {
    return &multiEchoerImpl{echo: echo}
}

func (s *multiEchoerImpl) MultiEcho(ctx context.Context, message string, times int) (string, error) {
    var b strings.Builder
    for i := 0; i < times; i++ {
        echoed, err := s.echo.Echo(ctx, message)
        if err != nil {
            return "", err
        }
        b.WriteString(echoed + "\n")
    }
    return b.String(), nil
}

In the above, MultiEchoer calls Echo by directly invoking s.echo.Echo.

The above is sufficient for the MultiEchoer service to be compiled and deployed, and to call the EchoService even if running in a different process, machine, or container.

Rules for Calling other Services

  • If a service calls another service, it can only receive a reference to the other service as a constructor argument, e.g. NewMultiEchoer(ctx context.Context, echo EchoService). It cannot instantiate the other service directly.

Backends

Some services want to persist data in backends, such as in a database, or make use of other features like a cache. Backends behave much like services: they have an interface, and Blueprint is responsible for compiling them.

Several backends are defined in Blueprint’s runtime module. To make use of them, use the following import:

1
import "github.com/blueprint-uservices/blueprint/runtime/core/backend"

We can update the MultiEchoer to use a backend.Cache and attempt to lookup cached entries.

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
type MultiEchoer struct {
    // Calls the EchoService n times
    MultiEcho(ctx context.Context, message string, times int) (string, error)
}

type multiEchoerImpl struct {
    echo EchoService
    cache backend.Cache
}

func NewMultiEchoer(ctx context.Context, echo EchoService, cache backend.Cache) (MultiEchoer, error) {
    return &multiEchoerImpl{echo: echo, cache: cache}
}

func (s *multiEchoerImpl) MultiEcho(ctx context.Context, message string, times int) (string, error) {
    var b strings.Builder
    for i := 0; i < times; i++ {
        var echoed string
        if err := s.cache.Get(ctx, message, &echoed); err != nil {
            // not present in cache; call EchoService
            echoed, err = s.echo.Echo(ctx, message)
            if err != nil {
                return "", err
            }
            s.cache.Put(ctx, message, echoed)
        }
        b.WriteString(echoed + "\n")
    }
    return b.String(), nil
}

Rules for backends

Backends do not impose any additional rules. Like services, they must be passed as constructor arguments.

List of backends

The runtime/core package provides the interfaces for a number of commonplace backends.

1
import "github.com/blueprint-uservices/blueprint/runtime/core/backend"
  • backend.Cache an interface for key-value caches; implementations for use in Wiring Specs include simplecache and memcached
  • backend.Queue an interface for queues with push/pop; implementations for use in Wiring Specs include simplequeue and rabbitmq
  • backend.NoSQLDatabase an interface for NoSQL databases that uses MongoDB-style BSON queries; implementations for use in Wiring Specs include simplenosqldb and mongodb
  • backend.RelationalDB an interface for SQL-based relational databases; implementations for use in Wiring Specs include simplereldb and mysql

Background Tasks

Some services might want to run additional background goroutines. For example, a service that polls a queue will need to have a goroutine to do so.

The recommended way to implement background goroutines is by implementing a method Run(context.Context) error. This will be automatically invoked in the generated code.

1
2
3
func (s *multiEchoerImpl) Run(ctx context.Context) error {
    fmt.Println("I'm running from a different goroutine!")
}
This post is licensed under CC BY 4.0 by the author.