I’m working several years with Go and MongoDB and decided once, that I write some Rust code with the official mongo-rust-driver to test new things.
I wouldn’t consider myself as a Rustacean
, but I do understand the basic principle of the borrowing, ownership and lifetimes in Rust. After reviewing the mongo-rust-driver docs, I started with the basic setup.
let db = Client::with_uri_str(mongodb_uri).await?.database("my_database")
After connecting it, I want to try out how to perform CRUD (Create, Read, Update and Delete) operations. To perform that, I have to create a collection. So I checked the docs again and saw the following:
mongodb::db::Database
pub fn collection<T>(&self, name: &str) -> Collection<T>
where
T: Send + Sync,
What does this mean?
Let us breakdown this Rust function:
- This function is part of the
Database
struct in themongodb::db
module. pub fn collection<T>
is declaring a public function with a generic type parameterT
(&self, name: &str)
are function parameters,&self
is a reference to theDatabase
instance on which the method is called.name: &str
is a string slice parameter with the name of the collection.
-> Collection<T>:
specifies the return typeCollection<T>
whereT
is the generic type parameter.where T: Send + Sync,:
is a trait bound to the generic type, which requires it thatT
has to implement theSend
andSync
trait.
In the end it is possible to infer a created type and create a collection with the following code:
let my_collection = db.collection::<model::MyStruct>("my_collection");
So why I’m so excited?
Let us have a look at the Go implementation for creating a collection.
// Collection gets a handle for a collection with the given name configured with the given CollectionOptions.
func (db *Database) Collection(name string, opts ...*options.CollectionOptions) *Collection {
return newCollection(db, name, opts...)
}
Let us breakdown this Go function:
- This function is part of the
Database
struct in themongo
package. func (db *Database) Collection
is declaring a public function due to the capitalized first character.(name string, opts ...*options.CollectionOptions)
are function parameters.name string
is a string parameter with the name of the collection.opts ...*options.CollectionOptions
is a parameter that represents options that can be used to configure a Collection.
- The function returns a reference of the
*Collection
instance.
So, the main difference, from the perspective of my usecase, is that when you create an Collection
instance you can’t infer a type in Go. This leads for a single read operation to the following code in Go:
db := client.Database("my_database").Collection("my_collection")
var data MyStruct
err := db.FindOne(ctx, filter).Decode(&data)
if err != nil {
return errors.New("custom error")
}
The same operation does look like the following in Rust:
let result = my_collection.find_one(filter).await?.ok_or(Error::CustomError)?;
See the difference? No, not the one-liner and the error handling. You don’t have to decode your operation result manually like in Go, instead, you’re using the power of type inference in Rust which will decode it for you under the hood.
I decided to write a small wrapper around the Go MongoDB driver, which allows me to use it almost the same way as the official mongo-rust-driver. For that I created a struct type named GenericMongoAdapter
with a generic type T
. [T any]
indicates that this is a generic type with a type parameter T
. The any
constraint means that T
can be any type.
type GenericMongoAdapter[T any] struct {
collection *mongo.Collection
}
func NewGenericMongoAdapter[T any](collection *mongo.Collection) GenericMongoAdapter[T] {
return GenericMongoAdapter[T]{collection: collection}
}
Also I created a function which returns an instance of GenericMongoAdapter
. After that it was easy to write down the CRUD operations for it:
func (g GenericMongoAdapter[T]) Create(ctx context.Context, data T) (primitive.ObjectID, error) {
result, err := g.collection.InsertOne(ctx, data)
if err != nil {
return nil, err
}
return result.InsertedID.(primitive.ObjectID), nil
}
func (g GenericMongoAdapter[T]) Read(ctx context.Context, query bson.M) (*T, error) {
var data *T
err := g.collection.FindOne(ctx, query).Decode(&data)
if err != nil {
return nil, err
}
return data, nil
}
func (g GenericMongoAdapter[T]) Update(ctx context.Context, query bson.M, updateObject bson.M, opts ...*options.UpdateOptions) error {
result, err := g.collection.UpdateOne(ctx,
query,
updateObject,
opts...,
)
if err != nil {
return nil, err
}
return nil
}
func (g GenericMongoAdapter[T]) Delete(ctx context.Context, query bson.M, opts ...*options.DeleteOptions) error {
result, err := g.collection.DeleteOne(ctx, query, opts...)
if err != nil {
return nil, err
}
return nil
}
After writing down these generic functions, let’s have a look on how to use them.
- First of all we are creating a MongoDB client instance, for this we have to connect to the database.
client, err := mongo.Connect(ctx, options.Client().ApplyURI(url))
if err != nil {
return nil, err
}
err = client.Ping(ctx, readpref.Primary())
if err != nil {
return nil, err
}
- Afterwards we are creating an instance of the collection we want to use.
myCollection := client.Database("my_database").Collection("my_collection")
- Thirdly, we are creating an instance of our
GenericMongoAdapter
. For this I’ll also define a struct.
type MyStruct struct {
ID primitive.ObjectID `bson:"_id"`
MyItems []string `bson:"my_items"`
}
myStructAdapter := genericAdapter.NewGenericMongoAdapter[MyStruct](myCollection)
- Lastly, we perform a read operation.
result, err := myStructAdapter.Read(ctx, bson.M{"_id": id})
if err != nil {
return nil, err
}
Now if we compare the read operation with the Rust equivalent.
let result = my_collection.find_one(filter).await?.ok_or(Error::CustomError)?;
From my point of view it is almost the same. And it saves you the boilerplate code with custom decoding in Go.
Summary
First of all both languages are having the right to exist and having their ups and downs. My intention was to show how to port the developer experience, which I faced in Rust, with the mongo-rust-driver in Go with the mongo-go-driver. My pain point was, the missing type inference for decoding and in terms of scaling it could lead to much boilerplate code.