avatarMidnight Firesale

Summary

The provided content outlines the process of building a file transfer service using Go and gRPC, detailing server and client implementations and concluding with encouragement for further development and customization.

Abstract

The article "Building a File Transfer Service with Go and gRPC (Part 2)" wraps up the design and implementation of a file transfer service initiated in Part 1. It provides a comprehensive guide to implementing the server-side FinishUpload API, which finalizes the upload process and responds to the client. The server implementation is complemented by the client-side setup using gRPC, including the StartUpload, Upload, and FinishUpload client APIs. The tutorial emphasizes the importance of flexible API design, idempotency, and the use of Prometheus for instrumentation. It also touches on the use of AWS services for backend storage solutions. The conclusion congratulates the reader on implementing a basic service and encourages further customization for production readiness.

Opinions

  • The author suggests that a flexible API design is crucial for allowing both server and client to manage file uploads efficiently.
  • Idempotency in the implementation of APIs is highlighted as a key feature to enable clients to repeat actions without harmful side effects.
  • The use of discrete steps in file transfers is recommended for easier debugging and servicing in production environments.
  • The author advocates for the use of interfaces in server implementations beyond gRPC-defined APIs to facilitate testing and dependency injection.
  • There is an emphasis on the need for additional work such as logging, error handling, and instrumentation to productionize the service.
  • The article encourages readers to follow the writer and the publication, suggesting that the content provided is part of a larger effort to democratize free programming education.

Building a File Transfer Service with Go and gRPC (Part 2)

Wrapping up our data transfer service

Photo by Markus Spiske on Unsplash

In Part 1 of building our gRPC Service, we uncovered the design and initial implementation of a file transfer service, written in Golang (Go) using gRPC. This design is mostly centered around a 3-part API where client code can start an upload, upload the raw bytes of the data (file), and then close the upload. To summarize:

  1. We break the API down into 3 parts to allow Server & Client a flexible arrangement: Server can deny an upload if validation checks don’t pass, and Client can choose to wait to upload or close until a later point in time.
  2. We left implementation loose on the examples, but client and server implementations can be done in a way to make the APIs fully idempotent. This allows the client to fail and repeat as often as it wishes with no harmful side effects.
  3. By making file transfers into discrete steps, debugging and servicing the code in production should become easier. Each of these three APIs can easily be instrumented with Prometheus or another metrics framework.

At the end of this Part 2 article, you should have more than enough to get a basic service headed in the right direction, using gRPC and Golang, with AWS or another cloud provider as your backend.

Server Implementation

FinishUpload API

In this section, we’ll implement the FinishUpload API on the server side. This API handles finalizing the upload process and responding to the client.

Create a file named finish_upload.go in the server directory and add the following code:

package main

import (
 "context"
 "log"
 "net"

 pb "path_to_your_protos/file_transfer"
 "google.golang.org/grpc"
)

// Reminder!  You may want to introduce an interface here and allow
// server to implement that behavior, for easier testing & dependency
// injection.
type server struct {
 pb.UnimplementedFileTransferServiceServer
}

func (s *server) FinishUpload(ctx context.Context, req *pb.FinishUploadRequest) (*pb.FinishUploadResponse, error) {
 // Validate the upload completion status (example)
 isSuccessful := validateUploadStatus(req.UploadId)

 if isSuccessful {
  return &pb.FinishUploadResponse{
   Status: pb.UploadStatus_SUCCESS,
  }, nil
 }

 return &pb.FinishUploadResponse{
  Status: pb.UploadStatus_FAILURE,
 }, nil
}

func validateUploadStatus(uploadID string) bool {
 // Implement logic to validate upload status
 // Example: Query the database for the upload status
 return true
}

In this implementation, the FinishUpload function receives the FinishUploadRequest from the client and checks the upload status. It returns a FinishUploadResponse with either a success or failure status, based on your implementation logic.

The validateUploadStatus function is a placeholder for validating the upload status. You can query the database or perform any necessary checks to determine if the upload was successful.

With this implementation, the server now has the capability to handle the entire file upload process. The StartUpload, Upload, and FinishUpload APIs are fully implemented.

‼️ Protip: If you decide to add on additional behavior on the server beyond the defined APIs from gRPC, introduce a dedicated interface for that! Your server can implement multiple interfaces, and it makes testing much easier. It’s already implementing the gRPC API interface, which allows you to mock and test those aspects elsewhere, but you can easily add in additional behavior into server (maybe some code that validates clients, or validates files) and define that as an interface type.

Client Implementation

gRPC Setup

Before we dive into the individual APIs, let’s set up the gRPC client. Create a file named main.go in the client directory and add the following code:

package main

import (
 "context"
 "fmt"
 "log"

 pb "path_to_your_protos/file_transfer"
 "google.golang.org/grpc"
)

func main() {
 // Set up a connection to the server
 conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure())
 if err != nil {
  log.Fatalf("Failed to connect: %v", err)
 }
 defer conn.Close()

 // Initialize the gRPC client
 client := pb.NewFileTransferServiceClient(conn)

 // Call the client APIs (we'll implement them in the following sections)
}

In this code, we establish a connection to the server using grpc.Dial and create a client using the generated NewFileTransferServiceClient function.

Now let’s proceed with the implementation of the individual client APIs.

StartUpload Client

Create a file named start_upload_client.go in the client directory and add the following code:

package main

import (
 "context"
 "fmt"
 "log"

 pb "path_to_your_protos/file_transfer"
)

func main() {
 / Set up a connection to the server
 conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure())
 if err != nil {
  log.Fatalf("Failed to connect: %v", err)
 }
 defer conn.Close()

 // Initialize the gRPC client
 client := pb.NewFileTransferServiceClient(conn)


 // Call the StartUpload API
 startUploadResponse, err := client.StartUpload(context.Background(), &pb.StartUploadRequest{
  FileName: "example.txt",  // Tip: this is often called the "stem", and we could omit 'txt.
  FileType: "txt",
  FileSize: 1024,
 })
 if err != nil {
  log.Fatalf("Error calling StartUpload: %v", err)
 }

 fmt.Printf("Upload ID: %s\n", startUploadResponse.UploadId)
}

In this code, we call the StartUpload API with a sample file metadata. The response includes the unique upload ID generated by the server.

Now, let’s implement the Upload and FinishUpload clients.

Upload Client

Create a file named upload_client.go in the client directory and add the following code:

package main

import (
 "context"
 "fmt"
 "io"
 "log"
 "os"

 pb "path_to_your_protos/file_transfer"
)

func main() {
 // Rest of the code remains the same...

 // Open the file for reading
 file, err := os.Open("path_to_your_file.txt")
 if err != nil {
  log.Fatalf("Error opening file: %v", err)
 }
 defer file.Close()

 // Create a stream to send the metadata and file data
 stream, err := client.Upload(context.Background())
 if err != nil {
  log.Fatalf("Error creating stream: %v", err)
 }

 // Send metadata to the server
 err = stream.Send(&pb.UploadRequest{
  UploadId: startUploadResponse.UploadId,
  FileName: "example.txt",
  FileType: "txt",
  FileSize: 1024,
 })
 if err != nil {
  log.Fatalf("Error sending metadata: %v", err)
 }

 // Send file data in chunks
 buf := make([]byte, 1024)
 for {
  n, err := file.Read(buf)
  if err == io.EOF {
   break
  }
  if err != nil {
   log.Fatalf("Error reading file: %v", err)
  }
  if err := stream.Send(&pb.UploadRequest{
   Chunk: buf[:n],
  }); err != nil {
   log.Fatalf("Error sending chunk: %v", err)
  }
 }

 // Close the stream and receive the response
 uploadResponse, err := stream.CloseAndRecv()
 if err != nil {
  log.Fatalf("Error receiving response: %v", err)
 }

 fmt.Println(uploadResponse.Message)
}

In this code, we open the file to be uploaded, create a stream to send the metadata and file data, and then send the data in chunks to the server. After sending all the data, we receive the response from the server.

FinishUpload Client

Create a file named finish_upload_client.go in the client directory and add the following code:

package main

import (
 "context"
 "fmt"
 "log"

 pb "path_to_your_protos/file_transfer"
)

func main() {
 // Rest of the code remains the same...

 // Call the FinishUpload API
 finishUploadResponse, err := client.FinishUpload(context.Background(), &pb.FinishUploadRequest{
  UploadId: "sample_unique_id", // Replace with actual upload ID
 })
 if err != nil {
  log.Fatalf("Error calling FinishUpload: %v", err)
 }

 if finishUploadResponse.Status == pb.UploadStatus_SUCCESS {
  fmt.Println("Upload completed successfully!")
 } else {
  fmt.Println("Upload failed.")
 }
}

In this code, we call the FinishUpload API with the upload ID obtained from the StartUpload response. We then process the response to determine whether the upload was successful or not.

Conclusion

Congratulations! You’ve successfully implemented a file transfer service using Go and gRPC. This tutorial covered setting up the project structure, creating gRPC API definitions, implementing the server-side APIs, and building the client-side applications. You’ve learned how to interact with AWS RDS for metadata storage and AWS S3 for file storage.

Feel free to customize and extend this implementation for your own use cases. Remember that this is a skeleton and general approach to writing a data transfer service, you’ll need an enormous amount of more work to productionize this (logging, error handling, instrumentation, completeness of the example API implementations, leveraging gRPC status package, and much more). This will get you going in the right direction, with an overall architecture design that is quite capable.

  • Enjoyed the Article? Leave me a 👏
  • Like the format and content? Join me here and give me a follow 🙌
  • Thank You! 🙏

Thank you for reading until the end. Please consider following the writer and this publication. Visit Stackademic to find out more about how we are democratizing free programming education around the world.

Golang
Software Development
Api Development
AWS
Programming
Recommended from ReadMedium