Working with Go and DGraph | Part 1

Jack Watts | Jun 17, 2023 min read

In this three part walkthrough we are going to create a mini web service using Go that connects with a DGraph instance and returns a DQL (dgraph query language) query via a ‘/query’ endpoint, both locally and running within a Kubernetes Cluster. the basics covered here should give anyone starting out the means to get onto the first rung of the ladder with Go and DGraph.

Overview

If you are not familair with the DGraph ecosystem it can be somewhat of a challenge to follow the docs and figure out how to go about setting it up correctly. This guide aims to show you how to set up and connect to DGraph in the following manner. Each number representing each subsequent part of the walkthrough series.

  1. Go Web Service and DGraph instance running locally.
  • Using Docker Desktop, we will start our Dgraph Instance
  • We will populate the DGraph instance with a Schema and seed it it dummy data
  • We will compile our Go code to start a webservice connected to the DGraph instance.
  • We will return a GraphQL query via our Go webservice.
  1. Go Web Service and DGraph instance running in Kubernetes.
  • We will set up a K8S Kustomization folder structure to build our images.
  • Via a makefile we will then set up a local K8S cluster and load our images.
  • We will return a DQL query via our Go webservice.
  1. Showcase using DGraph Cloud as a managed service.

NOTE: The purpose here is to showcase functionality which you can build on yourself. My focus was on getting code that works over best practice, styling etc.

INFO: It is recommended that you follow this tutorial from the start as each part increments on the previous. This will help you avoid mssing dependant information.

Pre-Requisites

For the sake of clarity, Table 1 covers the version information of the various tooling used.

Table 1 - Tooling Versions

Tool NameVersion
Docker Desktop20.10.22
Docker20.10.22
Go1.20
DGraph23.0.1
Kustomize4.5.7
kubectl1.26.2
kind0.17.0

Tooling is best installed via your native package manager, i.e brew or apt.

brew install go kind kubectl kustomize

We will control all operations with a makefile to make things as seamless as possible. So lets create one in our repo.

touch makefile
## Prepare docker

In your IDE of choice lets add the following lines.
# Set up makefile variables.
DGRAPH  := dgraph/dgraph:v23.0.0
RATEL 	:= dgraph/ratel:latest

# dev-docker: pulls all required docker images.
dev-docker:
	docker pull $(DGRAPH)
	docker pull $(RATEL)

# dev-dgraph-local: run the containers in docker.
dev-dgraph-local:
  docker run --name dgraph-local -d -it \
    -p "8080:8080" -p "9080:9080" -p "8090:8090" \
    -v ~/dgraph $(DGRAPH)
  docker run --name ratel-local  \
    --platform linux/amd64 -d -p "8000:8000"  $(RATEL)

Notice the ‘-p’ flag. This flag is handling the port forwarding mechanism that allows us to talk to our containers from outside their own respective containers. Hence when we browse the port 8000 on the ratel instance we get a UI as a UI is being serverd via the internal port of 8000.

Now in our terminal, lets execute these commands.

$ make dev-docker
$ make dev-docker-local

Visit http://localhost:8000 and you should be able to access the Ratel UI. The frontend controller for the DGraph backend. You will see a successful connection under the Server Connection menu.

Image Server Connection

Seed the database

With our local instances up and ruunning lets populate the database with a schema and push some data to work with. (In the repo wou will find sample schema files.)

Back in our makefile, lets add the following lines

dev-dgraph-seed:
  curl -X POST 'localhost:8080/admin/schema' --data-binary \
  '@./schema/schema.graphql'
  curl -X POST 'http://localhost:8080/graphql' \
  --header 'Content-Type: application/graphql' \
  --data-binary '@./schema/sample.data.graphql'

To populate the schema we need to target the schema endpoint on the admin route. You should see a JSON struct printed to STDOUT when successful.

We can verify the data migration by running a query against the GraphQL endpoint

curl -X POST 'http://localhost:8080/graphql' --header \
'Content-Type: application/graphql' --data-binary \
'@./schema/sample.query.graphql'

You will get the follwoing response.

{
  "data": {
    "queryReview": [
      {
        "comment": "Fantastic, easy to install, worked great. Best GraphQL server available",
        "by": {
          "username": "Jack Watts",
          "reviews": [
            {
              "about": {
                "name": "Trench Digital",
                "reviews": [
                  {
                    "by": {
                      "username": "Jack Watts"
                    },
                    "comment": "Pretty cool stuff!!",
                    "rating": 10
                  }
                ]
              },
              "rating": 10
            },
            {
              "about": {
                "name": "Dgraph",
                "reviews": [
                  {
                    "by": {
                      "username": "Jack Watts"
                    },
                    "comment": "Fantastic, easy to install, worked great. Best GraphQL server available",
                    "rating": 10
                  }
                ]
              },
              "rating": 10
            },
            {
              "about": {
                "name": "Dgraph Cloud",
                "reviews": [
                  {
                    "by": {
                      "username": "Jack Watts"
                    },
                    "comment": "Pretty nifty Cloud service",
                    "rating": 10
                  }
                ]
              },
              "rating": 10
            },
            {
              "about": {
                "name": "Fish Tank Cloud",
                "reviews": [
                  {
                    "by": {
                      "username": "Jack Watts"
                    },
                    "comment": "Awesome free APPs!",
                    "rating": 10
                  }
                ]
              },
              "rating": 10
            }
          ]
        },
        "about": {
          "name": "Dgraph"
        }
      }
    ]
  },
  "extensions": {
    "touched_uids": 63,
    "tracing": {
      "version": 1,
      "startTime": "2023-06-17T23:58:16.048289041Z",
      "endTime": "2023-06-17T23:58:16.068109541Z",
      "duration": 19820458,
      "execution": {
        "resolvers": [
          {
            "path": [
              "queryReview"
            ],
            "parentType": "Query",
            "fieldName": "queryReview",
            "returnType": "[Review]",
            "startOffset": 938958,
            "duration": 18850625,
            "dgraph": [
              {
                "label": "query",
                "startOffset": 1553833,
                "duration": 18232583
              }
            ]
          }
        ]
      }
    }
  }
}

Introducing Go

Alright, we have DGraph up and runnig and some data in place that we can work with. Let’s bring Go into the equation now and implememt the same functionality of the CURL commands above.

Back in our terminal with the PWD the root of our project, init a Go module.

# Be sure to set your namespace accordingly.
# For example, my NS is github.com/jack-watts/go-dgraph-starter
$ go mod init [path/to/your/module/namespace]

# Set up our Go service
$ mkdir -p app/services/api/ && touch app/services/api/main.go

In main.go, set some boilerplate code.

package main

import "fmt"

func main() {
	fmt.Println("Hello, DGraph")
}

Set up a basic web server

Alright, lets get a basic web service up and running before turning our attention to configuration.

Create a new function called run that will return an error and place our inital service startup here.

func run() error {
	log.Print("starting service")
	defer log.Print("shutdown complete")

	api := http.Server{}

	if err := api.ListenAndServe(); err != nil {
		return err
	}

	return nil
}

Back in our main function lets call run and handle the error response via log.Fatal which makes a call to os.Exit.

func main() {
	if err := run(); err != nil {
		log.Fatalf("Unable to start sevrice: %s\n", err)
	}
}

Adding our Go config

Create a new folder with a go file named config.go inside of it: ./app/services/api/config/config.go

package config

import (
	"net/http"
	"time"
)

type Config struct {
	Web   http.Server
	DgURL string
}

func (c *Config) Initialize() {
	c.Web.ReadTimeout = time.Second * 5
	c.Web.WriteTimeout = time.Second * 10
	c.Web.IdleTimeout = time.Second * 120
}

Now, modify the run() function to take a value of type Config and set the API server parameters.

func run(cfg *config.Config) error {

	log.Print("starting service")
	defer log.Print("shutdown complete")

	api := http.Server{
		Addr:         cfg.Web.Addr,
		ReadTimeout:  cfg.Web.ReadTimeout,
		WriteTimeout: cfg.Web.WriteTimeout,
		IdleTimeout:  cfg.Web.IdleTimeout,
	}

	if err := api.ListenAndServe(); err != nil {
		return err
	}

	return nil

}

Lets fo back to our main() function and initialize our Config with some default values using the flag package.

func main() {
	var cfg config.Config
	cfg.Initialize()

	// Set CMD Flags
	flag.StringVar(&cfg.Web.Addr, "web-host", "localhost:8081", \
  "set the hostname for the service. default: localhost:8081")
	flag.StringVar(&cfg.DgURL, "dgraph-host", "localhost:9080", \
  "set the DGraph host url. default: localhost:9080")

	if err := run(&cfg); err != nil {
		log.Fatalf("Unable to start sevrice: %s\n", err)
	}
}

Ok, before we go any further in connecting to DGraph, I want to stabilize our webservice so that it will gracefully shutdown should it be interupted.

## Supporting our web Server.

First lets set up management of any server errors that we may encounter. For this we need to create a channel that is listens for a any errors from our service.

In our main() function, lets rewrite how we are calling our webserver.

// From this
if err := api.ListenAndServe(); err != nil {
  return err
}

// To this
serverErrors := make(chan error, 1)

go func() {
  log.Printf("Starting api V1 router, host: %s\n", api.Addr)
  serverErrors <- api.ListenAndServe()
}()

We have created a channel with a buffer of 1 value that is expecting errors. Then with the <- syntax the channel receives a vlaue of Type error passed across Go routines as that is what we have wrapped our web server in. That said, we are not doing anything with our serverErrors channel right now so if you run the service, it will simply just start up and shutdown as no errors have gone into the channel. We need to handle our Shutdown listener conatined within the Server instance. So lets implement a graceful shutdown to our web server.

Create another channel called shutdown and pass this as the value to signal.Notify() which enables all incoming signals to be passed to the shutdown channel.

...
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
...

serverErrors := make(chan error, 1)

go func() {
  log.Printf("Starting api V1 router, host: %s\n", api.Addr)
  serverErrors <- api.ListenAndServe()
}()

Lets manage these channels on a conditional basis via a switch statement. In case serverErrors returns true, we will return the error and out of the main() function.

If a signal is dectected via the shutdown channel we initialise a context with the intent to release resources associated with it across a period of 20 seconds. This context is passed to the Shutdown method of our web server where Shutdown gracefully shuts down the server without interrupting any active connections. Shutdown works by first closing all open listeners, then closing all idle connections, and then waiting indefinitely for connections to return to idle and then shut down.

select {
case err := <-serverErrors:
  return fmt.Errorf("server error: %w", err)

case sig := <-shutdown:
  log.Printf("graceful shutdown started:signal:%s\n", sig)
  defer log.Printf("graceful shutdown complete:signal:%s\n", sig)

  ctx, cancel := context.WithTimeout(context.Background(), time.Second*20)
  defer cancel()

  if err := api.Shutdown(ctx); err != nil {
    api.Close()
    return fmt.Errorf("could not stop server gracefully: %w", err)
  }
}

Update our makefile to run our Go code.

dev-run:
  go run ./app/services/api

Execute our new makefile entry:

$ make dev-run
go run ./app/services/api
2023/06/17 14:41:04 starting service
2023/06/17 14:41:04 Starting api V1 router, host: localhost:8081

# Pressing CTRL+C will result in a graceful shutdown.
^C2023/06/17 14:42:57 graceful shutdown started:signal:interrupt
2023/06/17 14:42:57 graceful shutdown complete:signal:interrupt
2023/06/17 14:42:57 shutdown complete

OK - Lets connect to DGraph.

Connecting to DGraph

Lets install the DGraph client we are going to use.

```bash

$ go get github.com/dgraph-io/dgo/v230


Create a new go file called `dgraph.go` within `package main` and lets add our connection function.

```go
type CancelFunc func()

func getDgraphClient() (*dgo.Dgraph, CancelFunc) {
  conn, err := grpc.Dial("localhost:9080", grpc.WithTransportCredentials(insecure.NewCredentials()))
  if err != nil {
  	log.Fatal("While trying to dial gRPC")
  }

  dc := api.NewDgraphClient(conn)
  dg := dgo.NewDgraphClient(dc)

  return dg, func() {
  	if err := conn.Close(); err != nil {
  		log.Printf("Error while closing connection:%v", err)
  	}
  }
}

The Go client communicates with the server on the gRPC port (default value 9080)

You will notice that we now have three non standard libary packages in use.

"google.golang.org/grpc"
"github.com/dgraph-io/dgo/v230"
"github.com/dgraph-io/dgo/v230/protos/api"

NOTE the version (v230) in the dgo import namespace. This is very, very important for the client to work properly as there are significant changes from previous versions.

So lets run go mod tidy to clean things up.

Ok, lets call this function from ‘main.go’ in the run() function.

	dg, cancel := getDgraphClient()
	defer cancel()

	app := Application{
		DG: dg,
	}

At this point, our code will not compile as we have declared a variable of type Application. So we need to create this type. But why am I creating this type? getDgraphClient() returns a pointer to type Dgraph (*dgo.Dgraph). This type has been set up and I would like to retain use of it for the duration of the period my service is running. I do not want to be creating multiple clients each time I want to send a query. So we create a global Type that we will bind our service Router to and call upon for each respective handler we need to stand up.

type Application struct {
	DG *dgo.Dgraph
}

Setting up the service router

Create a new file called routes.go in package main. Add the following code. We will be using the Chi muxer for this walkthrough.

func (app *Application) routes() http.Handler {
	mux := chi.NewRouter()
	return mux
}

Rerun go mod tidy to import Chi. Now lets fo back to run() and append our http server to give it our new Chi router as the handler over the default setting.

	api := http.Server{
		Addr:         cfg.Web.Addr,
		// here we add our routes pointer refernece method
    Handler:      app.routes(), 
		ReadTimeout:  cfg.Web.ReadTimeout,
		WriteTimeout: cfg.Web.WriteTimeout,
		IdleTimeout:  cfg.Web.IdleTimeout,
	}

Great! Our service will run but we aren’t even doing anything yet! So lets change that and bring in some handlers! We are going to set two, one for the root “/” and one for a simple query “/query”.

Creating handlers

First things first, we are going to be writing JSON here , so lets create a writeJSON utils function to prevent ourselves from repeating code.

Create a utils.go foler under package main. The error is supressed as we will be doing the response validation in our handler functions.

func writeJSON(w http.ResponseWriter, data interface{}) {
	out, _ := json.MarshalIndent(data, "", "  ")
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(out)
}

Now create a handlers.go file under package main. For the sake of it we’ll keep things clean for the user so when they hit the root of our service i.e “/”, they receive a message that informs them of what routes they should be targeting.

func (app *Application) home(w http.ResponseWriter, r *http.Request) {
	var data = struct {
		Status string `json:"status"`
		URL    string `json:"urls"`
	}{
		Status: "active",
		URL:    "/query",
	}

	writeJSON(w, data)
}

Here we create a quick in line struct and print the response calling our utils writeJSON() function. And back in our pointer reference method routes() we add our route.

func (app *Application) routes() http.Handler {
  mux := chi.NewRouter()

	mux.Get("/", app.home) // <--- here

	return mux
}

Fire up our service make dev-run and browse to http://localhost:8081

Image Server Response

Success!

Ok, now lets add a query endpoint. Back in our handlers.go file lest add a query() pointer reference method where we create a new Transaction (a phrase that is part of the DGraph nomenclature), define or query in DQL, send the Query via our dgraph client. Remember we are running on our Application type here which has a nested reference to type Dgraph. This is why we created it earlier. Then we are back to good oul JSON response handling. Given the nature of the repsonse with respect to our schema we populated earlier, we are unmarshaling to a map to handle the unstructured data. A good example of not everything in the real world conforming to the Type philosphy! And finally we call our utils writeJSON helper funcion!

func (app *Application) query(w http.ResponseWriter, r *http.Request) {
	// create a new transaction
	txn := app.DG.NewReadOnlyTxn()

	// define our query
	const q = `{
		all(func: has(Product.reviews), first: 10) {
		  uid
		  expand(_all_)
		}
	  }`

	// pass our query as a transaction to get a response
	resp, err := txn.Query(context.Background(), q)
	if err != nil {
		log.Printf("error returning query response body: %s\n", err)
		return
	}

	var data interface{}

	// unmarshal the unstructured data to the 'data' interface.
	if err := json.Unmarshal(resp.GetJson(), &data); err != nil {
		log.Printf("%s\n", err)
	}

	writeJSON(w, data)

}

Last step here, lets add our “/query” handler to routes.go.

func (app *Application) routes() http.Handler {
	mux := chi.NewRouter()

	mux.Get("/", app.home)

	mux.Get("/query", app.query) // <-- here

	return mux
}

And thast it! Restart our service make dev-run and browse to http://localhost:8081/query

Image Server Response

In closing, dgraph is quite exotic to put it mildly as is a discipline into itself so forgive me for not delving too deep into the DQL side of things. From this point you should have the gist to move forward and introduce other transaction types. Be sure to check out the DGraph documention via the references below for more insight. I will be back for part 2 shortly!

Summary

In this walkthrough you learned

  • To set up DGraph in Docker
  • Seed the DGraph instance via its native GraphQL endpoint.
  • How to set up a web service in Go with graceful shutdown.
  • Connect to a dockerized DGraph instance via a Go web service.
  • Establish an endpoint that returns a query from the DGraph instance.

In part two, we will iterate over eveything we have done here once more, and tighten up our configuration as it is somewhat loose so we can build and load our service into a Kubernetes cluster running locally.

Source code can be found here
https://github.com/jack-watts/go-dgraph-starter

Gopher Images Credit Ashley Mcnamara. https://www.ashley.dev/projects/gopher-art

Read it on Medium here

References

Go - https://go.dev/
Dgraph - https://dgraph.io/
DGraph-IO - http://github.com/dgraph-io/dgo
Kustomize - https://kustomize.io/