logo
Menu
Unleashing CRUD Prowess: OpenSearch and Golang, a Dynamic Duo

Unleashing CRUD Prowess: OpenSearch and Golang, a Dynamic Duo

Let's explore the powerful combination of OpenSearch, a distributed and open-source database for search and analytics, with Golang — a statically typed, and compiled programming language. This blog post delves into leveraging the strengths of both technologies to build efficient and scalable CRUD (Create, Read, Update, and Delete) applications.

Ricardo Ferreira
Amazon Employee
Published Apr 7, 2024
Last Modified Apr 8, 2024
If you ever need to build an application for intensive searching of data, use a database that is optimized for such. OpenSearch is a great option for this. Its a real-time distributed search and analytics engine that provides full-text search capabilities and scales horizontally, while still providing sub-second latency for results. OpenSearch is an open-source project available under Apache License 2.0. You can work with OpenSearch locally, using distributions like Tarball, RPM, Debian, Windows, Docker, Helm, and Ansible playbooks. Alternatively, you can use OpenSearch with Amazon OpenSearch, a fully managed service for OpenSearch that allows you to focus on building solutions while the infrastructure and the operations are taken care for you.
In this tutorial, you will learn how to build an CRUD application for OpenSearch using the Go programming language. This blog post was created from the example of a Terraform provider found in this code repository. If you want a complete example of an application that uses Go to connect with OpenSearch, this repository will provide you with that.

Connecting with OpenSearch

The first thing you need to do to use OpenSearch with Go is importing the Go client for OpenSearch and the AWS SDK for Go if you intend to work with Amazon OpenSearch. You can do this either manually or using the tools provided by your IDE.
go get github.com/opensearch-project/opensearch-go
go get github.com/aws/aws-sdk-go-v2/aws
go get github.com/aws/aws-sdk-go-v2/config
As a developer, the most important thing to understand about OpenSearch is all its operations are available to the world via REST APIs. This is true for Go and any other programming language. For this reason, your code must have a client connection with the cluster.
In Go, use the opensearch.NewClient() function to create a new client.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const endpoint = "https://community-aws-os-with-golang.us-east-1.es.amazonaws.com"

func main() {

client, err := opensearch.NewClient(opensearch.Config{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
},
Addresses: []string{endpoint},
Username: "<YOUR_USERNAME>",
Password: "<YOUR_PASSWORD>",
})

if err != nil {
log.Fatal(err)
}

}
This function expects a single parameter containing the configuration necessary to establish the HTTP connection with OpenSearch. With the opensearch.Config struct you can define this configuration. It supports many fields that you can use to access different OpenSearch deployments. The most important field is the Addresses array, which expects you to inform one or multiple endpoints that can establish the connection. All the other fields are optional, and you may use them when it makes sense.
For instance, the code shown above is enforcing SSL verification, which is why a value was provided for the field Transport. Similarly, it provides a username and a password using the fields Username and Password, to authenticate with the cluster exposed at the provided endpoint. This approach may work well if your cluster is running on-premises, or if your Amazon OpenSearch cluster is configured to use the master database for authentication.
However, if you have configured Amazon OpenSearch to use IAM authentication, you are going to need a different approach to create the client.
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
34
35
36
37
38
39
40
41
42
43
func main() {

ctx := context.Background()

awsCfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("<AWS_REGION>"),
config.WithCredentialsProvider(
getCredentialProvider(
"<AWS_ACCESS_KEY>",
"<AWS_SECRET_ACCESS_KEY>",
"<AWS_SESSION_TOKEN>"),
),
)
if err != nil {
log.Fatal(err)
}

signer, err := requestsigner.NewSignerWithService(awsCfg, "es")
if err != nil {
log.Fatal(err)
}

client, err := opensearch.NewClient(opensearch.Config{
Addresses: []string{endpoint},
Signer: signer,
})

if err != nil {
log.Fatal("client creation err", err)
}

}

func getCredentialProvider(accessKey, secretAccessKey, token string) aws.CredentialsProviderFunc {
return func(ctx context.Context) (aws.Credentials, error) {
c := &aws.Credentials{
AccessKeyID: accessKey,
SecretAccessKey: secretAccessKey,
SessionToken: token,
}
return *c, nil
}
}
This approach is slightly more elaborated than the previous one, but it remains semantically the same. This is the key difference:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Before
client, err := opensearch.NewClient(opensearch.Config{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
},
Addresses: []string{endpoint},
Username: "<YOUR_USERNAME>",
Password: "<YOUR_PASSWORD>",
})

// After
client, err := opensearch.NewClient(opensearch.Config{
Addresses: []string{endpoint},
Signer: signer,
})
When using IAM for authentication, instead of providing credentials for the OpenSearch cluster, you provide the credentials of an IAM user allowed to connect with OpenSearch using a signer. A signer encapsulates everything you need to connect with the service, such as your AWS access key, secret access key, and token. Once everything is set into the signer, you can authenticate with Amazon OpenSearch.

Checking if the connection is valid

Before using the client to send requests to OpenSearch, it is a good idea to always check if the connection is valid first. Doing this will save you from connectivity headaches, hours of debugging, and improve your code robustness.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pingRequest := opensearchapi.PingRequest{
Pretty: true,
Human: true,
ErrorTrace: true,
}

pingResponse, err := pingRequest.Do(ctx, client)

if err != nil {
log.Fatal(err)
} else {
if pingResponse.StatusCode == http.StatusOK {
log.Debug("🎵 Don't worry about a thing. " +
"'Cause every little thing gonna be alright.")
}
}
Note that we have used http.StatusOK from Go to compare the status of the ping request. As mentioned earlier, OpenSearch makes all its operations available to the world via REST APIs. Therefore, if you need to check if the connection has been successfully established, you will check if the status is 200. Similarly, if you need to check if the authentication failed, you will check if the status is equal to 401 or 403, for example.

Indexing a new document

Now that you have a valid client to work with, you can use this client to index documents. In OpenSearch, the operation of writing data is called indexing, and the data itself is called documents. Let's say you want to index the following document:
1
2
3
4
5
6
{
"fullname": "Deadpool",
"identity": "Wade Wilson",
"knownas": "Merch with a mouth",
"type": "anti-hero"
}
You would need a struct to represent this document in your code.
1
2
3
4
5
6
7
type ComicCharacter struct {
ID string `json:"_id,omitempty"`
FullName string `json:"fullname,omitempty"`
Identity string `json:"identity,omitempty"`
KnownAs string `json:"knownas,omitempty"`
Type string `json:"type,omitempty"`
}
Along with the fields of the document, it was included an additional field called ID in the struct. This will hold the document identifier, which is a string that uniquely identifies each document on OpenSearch. This is how you are going to index documents.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
comicCharacter := &ComicCharacter{
FullName: "Deadpool",
Identity: "Wade Wilson",
KnownAs: "Merch with a mouth",
Type: "anti-hero",
}

bodyContent, _ := json.Marshal(comicCharacter)
bodyReader := bytes.NewReader(bodyContent)

indexRequest := opensearchapi.IndexRequest{
Index: "build-on-aws",
Body: bodyReader,
}

indexResponse, err := indexRequest.Do(ctx, client)

if err != nil {
log.Error(err)
}
Similar to checking if the connection is valid, indexing a document requires a request and response interaction with OpenSearch. Once a request is executed, a response will be replied and it is up to you to process it. Here, we will process the response, as OpenSearch always returns the document identifier of the newly indexed document. To fully process the response, we need a struct that represents the response.
1
2
3
4
type BackendResponse struct {
ID string `json:"_id"`
Source *ComicCharacter `json:"_source"`
}
Now you can simply:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
defer indexResponse.Body.Close()
bodyContent, err = io.ReadAll(indexResponse.Body)

if err != nil {
log.Error(err)
}

response := &BackendResponse{}
err = json.Unmarshal(bodyContent, response)

if err != nil {
log.Error(err)
}

documentID := response.ID
log.Info(documentID)

Reading an existing document

If you have the document identifier of a specific document, you don't need to search for the document. Instead, you can simply execute a look up. Given the documentID value retrieved earlier, here is how you can look up the document and write the value of the FullName field to a variable called fullName.
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
getRequest := opensearchapi.GetRequest{
Index: "build-on-aws",
DocumentID: documentID,
}

getResponse, err := getRequest.Do(ctx, client)

if err != nil {
log.Error(err)
}

defer getResponse.Body.Close()
bodyContent, err := io.ReadAll(getResponse.Body)

if err != nil {
log.Error(err)
}

response := &BackendResponse{}
err = json.Unmarshal(bodyContent, response)

if err != nil {
log.Error(err)
}

log.Info(response.Source.FullName)

Updating an existing document

To update an existing document, you will need the document identifier of the document. Then you will need to provide a new document containing only the fields whose values you want to update. Here is how we are going to update the KnownAs field of the Deadpool character to Weapon XI.
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
updatedDocument := &struct {
Doc ComicCharacter `json:"doc,omitempty"`
}{
Doc: ComicCharacter{
KnownAs: "Weapon XI",
},
}

bodyContent, err := json.Marshal(updatedDocument)

if err != nil {
log.Error(err)
}

bodyReader := bytes.NewReader(bodyContent)
updateRequest := opensearchapi.UpdateRequest{
Index: "build-on-aws",
DocumentID: documentID,
Body: bodyReader,
}

_, err = updateRequest.Do(ctx, client)

if err != nil {
log.Error(err)
}
Different from the create and read operations, you don't need to process the response returned by OpenSearch. If there weren't any errors returned, you can be sure that the document is going to be updated. Moreover, it is important to understand that OpenSearch applies eventual consistency for updates.
Internally, the old document is marked as deleted and a new document is added. The old version of the document doesn't disappear immediately, but you won't be able to access it. OpenSearch cleans up deleted documents in the background as you continue to index more data.

Deleting an existing document

The process to delete an existing document is fairly simple, as the only thing needed is the document identifier. Here is how you can delete the document containing the Deadpool character.
1
2
3
4
5
6
7
8
9
10
deleteRequest := opensearchapi.DeleteRequest{
Index: backendIndex,
DocumentID: documentID,
}

_, err := deleteRequest.Do(ctx, client)

if err != nil {
log.Error(err)
}
Once OpenSearch receives this request, it will mark the existing document as deleted and a background process will delete it, eventually.

Going beyond CRUD operations

A database such as OpenSearch that is optimized for intensive searching of data is not very useful if you don't send any searches for it, right? At this point, you have learned how to perform CRUD operations but not how to do searches. The good news is that the process is similar to CRUD operations. It requires a request and response interaction with OpenSearch.
For instance, let's say you want to search for all the characters given their identity. To write this search, you need to leverage the Query DSL feature, and create a search body using one of the supported full-text queries from OpenSearch.
To illustrate this, let's say you want to search for a character whose identity is Matt Murdock. If anything is found, you will retrieve the first document returned and read the FullName field into a variable called fullName.
The first step is to create a query of type Match. It allows you to perform full-text search using a specific document field. In this case, the field Identity. In Query DSL, this is how you would implement this query:
1
2
3
4
5
6
7
8
GET _search
{
"query": {
"match": {
"identity": "Matt Murdock"
}
}
}
In Go, this is how you are going to implement this query:
1
2
3
4
5
6
7
8
9
searchBody := &struct {
Query struct {
Match struct {
Identity string `json:"identity,omitempty"`
} `json:"match,omitempty"`
} `json:"query,omitempty"`
}{}

searchBody.Query.Match.Identity = "Matt Murdock"
With the query created, you can use it to perform a search request:
1
2
3
4
5
6
7
8
9
10
11
12
13
bodyContent, _ := json.Marshal(searchBody)
bodyReader := bytes.NewReader(bodyContent)

searchRequest := opensearchapi.SearchRequest{
Index: []string{"build-on-aws"},
Body: bodyReader,
}

searchResponse, err := searchRequest.Do(ctx, client)

if err != nil {
log.Error(err)
}
Since you need to process the response as you are interested in the results of the search, you will need a struct in your code that represents the result returned by OpenSearch:
1
2
3
4
5
6
7
8
9
10
11
type BackendSearchResponse struct {
Hits struct {
Total struct {
Value int64 `json:"value"`
} `json:"total"`
Hits []*struct {
ID string `json:"_id"`
Source *ComicCharacter `json:"_source"`
} `json:"hits"`
} `json:"hits"`
}
Finally, this is how you are going to read the results:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
defer searchResponse.Body.Close()
bodyContent, err = io.ReadAll(searchResponse.Body)

if err != nil {
log.Error(err)
}

response := &BackendSearchResponse{}
err = json.Unmarshal(bodyContent, response)

if err != nil {
log.Error(err)
}

if response.Hits.Total.Value > 0 {
character := response.Hits.Hits[0].Source
log.Info(character.FullName)
} else {
log.Warn("No characters has been found.")
}
If you are acquainted with the Marvel universe, you know that any searches for someone whose identity is Matt Murdock will return the character Daredevil. Also, did you notice that it's the t-shirt I am wearing in the banner of this blog post? If caught that, add a comment below.

Implementing searches using SQL

Now that you learned how to implement searches using the Query DSL, let's take one step further and implement the same query before but now using SQL. OpenSearch provides native support for SQL, which can come in handy if you are new to OpenSearch but have a previous background with SQL databases.
To implement the previous query using SQL, you need to execute the following statement:
SELECT * FROM build-on-aws WHERE identity IS 'Matt Murdock'
Here, you are searching for the character Matt Murdock in the build-on-aws index, which is the index where all the documents have been written. OpenSearch allows you to execute this SQL query using a plugin. Therefore, you need to write a code that will send an SQL query request to be executed in this plugin.
There is no high-level API in the Go client for OpenSearch that allows you to interact with the plugins. However, you can send virtually any HTTP request to the client created before and, if the request is well constructed, you will get the same results as if you were using a high-level API. Here is how you can do this.
First, you need to create a body that will contain your SQL statement. Then, this body must be serialized into a io.Reader just like you have done in the previous version of the search implementation.
1
2
3
4
5
6
7
8
sqlQuery := &struct {
Query string `json:"query,omitempty"`
}{
Query: "SELECT * FROM build-on-aws WHERE identity IS 'Matt Murdock'",
}

bodyContent, _ := json.Marshal(sqlQuery)
bodyReader := bytes.NewReader(bodyContent)
Next, you need to create a valid HTTP request and execute using the client, which acts as the transform layer for your HTTP request.
1
2
3
4
5
6
7
8
9
10
11
12
sqlSearchRequest, err := http.NewRequest("POST", "_plugins/_sql?format=json", bodyReader)
sqlSearchRequest.Header.Add("Content-type", "application/json")

if err != nil {
log.Error(err)
}

sqlSearchResponse, err := client.Perform(sqlSearchRequest)

if err != nil {
log.Error(err)
}
If no errors are returned, you can process the response just like you did before, even using the same struct that provides a representation of what OpenSearch sends you back.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
defer sqlSearchResponse.Body.Close()
bodyContent, err = io.ReadAll(sqlSearchResponse.Body)

if err != nil {
log.Error(err)
}

response := &BackendSearchResponse{}
err = json.Unmarshal(bodyContent, response)

if err != nil {
log.Error(err)
}

if response.Hits.Total.Value > 0 {
character := response.Hits.Hits[0].Source
log.Info(character.FullName)
} else {
log.Warn("No characters has been found.")
}
Follow me on LinkedIn if you want to geek out about technologies like OpenSearch, Golang, and many others.
See you, next time!
 

Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.

Comments