Skip to main content

Running Multiple gRPC Servers in Parallel

This tutorial will guide you through setting up and running multiple gRPC servers in parallel using the Gonyx Framework. You'll learn how to configure multiple gRPC servers in your application and assign specific services to different servers.

Prerequisites

Before starting, make sure you have:

  • Completed the Quick Start tutorial
  • Basic knowledge of Go programming language and gRPC
  • Understanding of Protocol Buffers
  • Gonyx Framework installed on your system
  • Familiarity with the Building a gRPC Weather Service tutorial is helpful

Why Run Multiple gRPC Servers?

Running multiple gRPC servers in parallel can be beneficial for several reasons:

  1. Service isolation: Keep different service domains separate for better modularity
  2. Security boundaries: Apply different security policies to different service groups
  3. Performance optimization: Dedicate resources for critical vs. non-critical services
  4. Independent scaling: Scale different service groups according to their needs
  5. Deployment flexibility: Deploy and update services independently
  6. Different ports/interfaces: Expose internal vs. external services on different network interfaces

Project Setup

First, create a new Gonyx project using the CLI:

gonyx init multi-grpc-demo --path .
cd multi-grpc-demo

This will create a basic project structure as described in the Quick Start tutorial.

Step 1: Define Protocol Buffer Services

For this tutorial, we'll create two separate gRPC services:

  1. User Service: For user management operations
  2. Notification Service: For handling system notifications

Let's start by defining both services in separate proto files:

User Service

Create a file named app/proto/user.proto:

syntax = "proto3";

package user;
option go_package = "multi-grpc-demo/app/proto/user";

// UserService handles user management operations
service UserService {
// Create a new user
rpc CreateUser(CreateUserRequest) returns (UserResponse) {}

// Get user by ID
rpc GetUser(GetUserRequest) returns (UserResponse) {}

// Update user information
rpc UpdateUser(UpdateUserRequest) returns (UserResponse) {}

// Delete a user
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {}
}

// CreateUserRequest for creating a new user
message CreateUserRequest {
string username = 1;
string email = 2;
string full_name = 3;
}

// GetUserRequest for retrieving a user
message GetUserRequest {
string user_id = 1;
}

// UpdateUserRequest for modifying user data
message UpdateUserRequest {
string user_id = 1;
optional string username = 2;
optional string email = 3;
optional string full_name = 4;
}

// DeleteUserRequest for removing a user
message DeleteUserRequest {
string user_id = 1;
}

// UserResponse contains user information
message UserResponse {
string user_id = 1;
string username = 2;
string email = 3;
string full_name = 4;
string created_at = 5;
string updated_at = 6;
}

// DeleteUserResponse acknowledges user deletion
message DeleteUserResponse {
bool success = 1;
string message = 2;
}

Notification Service

Create a file named app/proto/notification.proto:

syntax = "proto3";

package notification;
option go_package = "multi-grpc-demo/app/proto/notification";

// NotificationService handles system notifications
service NotificationService {
// Send a notification to users
rpc SendNotification(SendNotificationRequest) returns (SendNotificationResponse) {}

// Get notifications for a user
rpc GetUserNotifications(UserNotificationsRequest) returns (UserNotificationsResponse) {}

// Mark notifications as read
rpc MarkAsRead(MarkAsReadRequest) returns (MarkAsReadResponse) {}

// Subscribe to real-time notifications
rpc SubscribeToNotifications(SubscriptionRequest) returns (stream NotificationEvent) {}
}

// SendNotificationRequest for sending notifications
message SendNotificationRequest {
string title = 1;
string message = 2;
string notification_type = 3;
repeated string recipient_ids = 4;
map<string, string> metadata = 5;
}

// SendNotificationResponse acknowledges notification sending
message SendNotificationResponse {
string notification_id = 1;
int32 recipients_count = 2;
string sent_at = 3;
}

// UserNotificationsRequest to get notifications for a user
message UserNotificationsRequest {
string user_id = 1;
bool include_read = 2;
int32 limit = 3;
int32 offset = 4;
}

// NotificationData represents a single notification
message NotificationData {
string notification_id = 1;
string title = 2;
string message = 3;
string notification_type = 4;
bool read = 5;
string created_at = 6;
map<string, string> metadata = 7;
}

// UserNotificationsResponse contains a list of notifications
message UserNotificationsResponse {
repeated NotificationData notifications = 1;
int32 total_count = 2;
int32 unread_count = 3;
}

// MarkAsReadRequest to mark notifications as read
message MarkAsReadRequest {
string user_id = 1;
repeated string notification_ids = 2;
}

// MarkAsReadResponse acknowledges the read status update
message MarkAsReadResponse {
int32 marked_count = 1;
bool success = 2;
}

// SubscriptionRequest for subscribing to notifications
message SubscriptionRequest {
string user_id = 1;
repeated string notification_types = 2;
}

// NotificationEvent represents a real-time notification event
message NotificationEvent {
NotificationData notification = 1;
string event_time = 2;
}

After creating these proto files, compile them using the Gonyx CLI:

gonyx compile user
gonyx compile notification

This will generate the Go code for both services in the appropriate directories.

Step 2: Configure Multiple gRPC Servers

Now we need to configure multiple gRPC servers in the configs/protobuf.json file. We'll set up two servers:

  1. User Server: For user-related services (internal)
  2. Public Server: For notification services (external)

Create the configs/protobuf.json file with the following content:

{
"proto": 3,
"servers": [
"user_server",
"public_server"
],
"user_server": {
"host": "127.0.0.1",
"port": 7777,
"protocol": "tcp",
"async": true,
"reflection": true,
"configs": {
"maxReceiveMessageSize": 104857600,
"maxSendMessageSize": 1048576000
}
},
"public_server": {
"host": "0.0.0.0",
"port": 7778,
"protocol": "tcp",
"async": true,
"reflection": true,
"configs": {
"maxReceiveMessageSize": 4194304,
"maxSendMessageSize": 4194304
}
}
}

In this configuration:

  1. We've defined two gRPC servers:

    • user_server: Runs on localhost port 7777 (for internal use only)
    • public_server: Runs on all interfaces (0.0.0.0) port 7778 (accessible externally)
  2. The servers array lists both servers, making them active when the application runs

  3. Each server has its own:

    • Host and port
    • Protocol configuration
    • Message size limits (larger for internal, more restricted for public)

Step 3: Implement Service Controllers

Now let's implement controllers for both services:

User Service Controller

Create the user service controller in your app/controller.go file:

package app

import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"multi-grpc-demo/app/proto/user"
)

// UserServiceController implements the user service
type UserServiceController struct {
user.UnimplementedUserServiceServer
// Simple in-memory user storage for demo purposes
users map[string]*user.UserResponse
}

// NewUserServiceController creates a new user service controller
func NewUserServiceController() *UserServiceController {
return &UserServiceController{
users: make(map[string]*user.UserResponse),
}
}

// GetName returns the controller name for Gonyx
func (ctrl *UserServiceController) GetName() string {
return "UserService"
}

// GetServerNames indicates which gRPC servers this controller should be registered with
func (ctrl *UserServiceController) GetServerNames() []string {
return []string{"user_server"} // This service will only run on the internal server
}

// CreateUser implements the CreateUser RPC
func (ctrl *UserServiceController) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.UserResponse, error) {
// Validate request
if req.Username == "" || req.Email == "" {
return nil, status.Error(codes.InvalidArgument, "username and email are required")
}

// Check for duplicate username
for _, u := range ctrl.users {
if u.Username == req.Username {
return nil, status.Error(codes.AlreadyExists, "username already exists")
}
if u.Email == req.Email {
return nil, status.Error(codes.AlreadyExists, "email already exists")
}
}

// Create user
now := time.Now().Format(time.RFC3339)
userId := uuid.New().String()

userResponse := &user.UserResponse{
UserId: userId,
Username: req.Username,
Email: req.Email,
FullName: req.FullName,
CreatedAt: now,
UpdatedAt: now,
}

// Store user
ctrl.users[userId] = userResponse

return userResponse, nil
}

// GetUser implements the GetUser RPC
func (ctrl *UserServiceController) GetUser(ctx context.Context, req *user.GetUserRequest) (*user.UserResponse, error) {
// Validate request
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}

// Get user
userResponse, exists := ctrl.users[req.UserId]
if !exists {
return nil, status.Error(codes.NotFound, "user not found")
}

return userResponse, nil
}

// UpdateUser implements the UpdateUser RPC
func (ctrl *UserServiceController) UpdateUser(ctx context.Context, req *user.UpdateUserRequest) (*user.UserResponse, error) {
// Validate request
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}

// Get user
userResponse, exists := ctrl.users[req.UserId]
if !exists {
return nil, status.Error(codes.NotFound, "user not found")
}

// Update fields if provided
if req.Username != nil {
userResponse.Username = *req.Username
}
if req.Email != nil {
userResponse.Email = *req.Email
}
if req.FullName != nil {
userResponse.FullName = *req.FullName
}

// Update timestamp
userResponse.UpdatedAt = time.Now().Format(time.RFC3339)

return userResponse, nil
}

// DeleteUser implements the DeleteUser RPC
func (ctrl *UserServiceController) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (*user.DeleteUserResponse, error) {
// Validate request
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}

// Check if user exists
_, exists := ctrl.users[req.UserId]
if !exists {
return nil, status.Error(codes.NotFound, "user not found")
}

// Delete user
delete(ctrl.users, req.UserId)

return &user.DeleteUserResponse{
Success: true,
Message: fmt.Sprintf("User %s successfully deleted", req.UserId),
}, nil
}

Notification Service Controller

Now let's implement the notification service controller in the same file:

package app

import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"multi-grpc-demo/app/proto/notification"
)

// NotificationServiceController implements the notification service
type NotificationServiceController struct {
notification.UnimplementedNotificationServiceServer
notifications map[string]*notification.NotificationData
userNotifications map[string][]string // user_id -> notification_ids
subscribers map[string][]notification.NotificationService_SubscribeToNotificationsServer
subscribersMutex sync.RWMutex
}

// NewNotificationServiceController creates a new notification service controller
func NewNotificationServiceController() *NotificationServiceController {
return &NotificationServiceController{
notifications: make(map[string]*notification.NotificationData),
userNotifications: make(map[string][]string),
subscribers: make(map[string][]notification.NotificationService_SubscribeToNotificationsServer),
}
}

// GetName returns the controller name for Gonyx
func (ctrl *NotificationServiceController) GetName() string {
return "NotificationService"
}

// GetServerNames indicates which gRPC servers this controller should be registered with
func (ctrl *NotificationServiceController) GetServerNames() []string {
return []string{"public_server"} // This service will run on the public server
}

// SendNotification implements the SendNotification RPC
func (ctrl *NotificationServiceController) SendNotification(ctx context.Context, req *notification.SendNotificationRequest) (*notification.SendNotificationResponse, error) {
// Validate request
if req.Title == "" || req.Message == "" {
return nil, status.Error(codes.InvalidArgument, "title and message are required")
}
if len(req.RecipientIds) == 0 {
return nil, status.Error(codes.InvalidArgument, "at least one recipient is required")
}

// Create notification
now := time.Now().Format(time.RFC3339)
notificationId := uuid.New().String()

notificationData := &notification.NotificationData{
NotificationId: notificationId,
Title: req.Title,
Message: req.Message,
NotificationType: req.NotificationType,
Read: false,
CreatedAt: now,
Metadata: req.Metadata,
}

// Store notification
ctrl.notifications[notificationId] = notificationData

// Associate with recipients
for _, userId := range req.RecipientIds {
if _, exists := ctrl.userNotifications[userId]; !exists {
ctrl.userNotifications[userId] = []string{}
}
ctrl.userNotifications[userId] = append(ctrl.userNotifications[userId], notificationId)

// Notify subscribers in a non-blocking way
go ctrl.notifySubscribers(userId, notificationData)
}

return &notification.SendNotificationResponse{
NotificationId: notificationId,
RecipientsCount: int32(len(req.RecipientIds)),
SentAt: now,
}, nil
}

// GetUserNotifications implements the GetUserNotifications RPC
func (ctrl *NotificationServiceController) GetUserNotifications(ctx context.Context, req *notification.UserNotificationsRequest) (*notification.UserNotificationsResponse, error) {
// Validate request
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}

// Get notification IDs for user
notificationIds, exists := ctrl.userNotifications[req.UserId]
if !exists {
// No notifications for this user
return &notification.UserNotificationsResponse{
Notifications: []*notification.NotificationData{},
TotalCount: 0,
UnreadCount: 0,
}, nil
}

// Apply pagination
offset := int(req.Offset)
if offset < 0 {
offset = 0
}

limit := int(req.Limit)
if limit <= 0 {
limit = 10 // Default limit
}

// Calculate end index for pagination
endIndex := offset + limit
if endIndex > len(notificationIds) {
endIndex = len(notificationIds)
}

// Get notifications subset
var userNotifications []*notification.NotificationData
var unreadCount int32

for _, id := range notificationIds {
n := ctrl.notifications[id]
if !n.Read {
unreadCount++
}

// Skip if we're only including unread and this is read
if !req.IncludeRead && n.Read {
continue
}

// Add to results if in pagination range
if len(userNotifications) < limit {
userNotifications = append(userNotifications, n)
}
}

return &notification.UserNotificationsResponse{
Notifications: userNotifications,
TotalCount: int32(len(notificationIds)),
UnreadCount: unreadCount,
}, nil
}

// MarkAsRead implements the MarkAsRead RPC
func (ctrl *NotificationServiceController) MarkAsRead(ctx context.Context, req *notification.MarkAsReadRequest) (*notification.MarkAsReadResponse, error) {
// Validate request
if req.UserId == "" {
return nil, status.Error(codes.InvalidArgument, "user ID is required")
}
if len(req.NotificationIds) == 0 {
return nil, status.Error(codes.InvalidArgument, "at least one notification ID is required")
}

markedCount := 0

// Mark notifications as read
for _, notificationId := range req.NotificationIds {
n, exists := ctrl.notifications[notificationId]
if exists && !n.Read {
n.Read = true
markedCount++
}
}

return &notification.MarkAsReadResponse{
MarkedCount: int32(markedCount),
Success: markedCount > 0,
}, nil
}

// SubscribeToNotifications implements the streaming RPC for real-time notifications
func (ctrl *NotificationServiceController) SubscribeToNotifications(req *notification.SubscriptionRequest, stream notification.NotificationService_SubscribeToNotificationsServer) error {
// Validate request
if req.UserId == "" {
return status.Error(codes.InvalidArgument, "user ID is required")
}

// Register this stream as a subscriber for the user
ctrl.subscribersMutex.Lock()
if _, exists := ctrl.subscribers[req.UserId]; !exists {
ctrl.subscribers[req.UserId] = []notification.NotificationService_SubscribeToNotificationsServer{}
}
ctrl.subscribers[req.UserId] = append(ctrl.subscribers[req.UserId], stream)
ctrl.subscribersMutex.Unlock()

// Keep the stream open until client disconnects or context is canceled
<-stream.Context().Done()

// Remove this stream from subscribers when disconnected
ctrl.removeSubscriber(req.UserId, stream)

return nil
}

// Helper method to notify subscribers
func (ctrl *NotificationServiceController) notifySubscribers(userId string, notification *notification.NotificationData) {
ctrl.subscribersMutex.RLock()
subscribers, exists := ctrl.subscribers[userId]
ctrl.subscribersMutex.RUnlock()

if !exists || len(subscribers) == 0 {
return
}

event := &notification.NotificationEvent{
Notification: notification,
EventTime: time.Now().Format(time.RFC3339),
}

// Send to all subscribers
for _, subscriber := range subscribers {
err := subscriber.Send(event)
if err != nil {
// On error, we'll clean up this subscriber later when its context is done
fmt.Printf("Error sending notification to subscriber: %v\n", err)
}
}
}

// Helper method to remove a subscriber
func (ctrl *NotificationServiceController) removeSubscriber(userId string, streamToRemove notification.NotificationService_SubscribeToNotificationsServer) {
ctrl.subscribersMutex.Lock()
defer ctrl.subscribersMutex.Unlock()

subscribers, exists := ctrl.subscribers[userId]
if !exists {
return
}

// Filter out the stream to remove
updatedSubscribers := make([]notification.NotificationService_SubscribeToNotificationsServer, 0, len(subscribers))
for _, sub := range subscribers {
if sub != streamToRemove {
updatedSubscribers = append(updatedSubscribers, sub)
}
}

if len(updatedSubscribers) == 0 {
delete(ctrl.subscribers, userId)
} else {
ctrl.subscribers[userId] = updatedSubscribers
}
}

Step 4: Register Controllers in App.go

Finally, let's update the app/app.go file to register both controllers with their respective gRPC servers:

package app

import (
"github.com/Blocktunium/gonyx/pkg/engine"
"google.golang.org/grpc"
"multi-grpc-demo/app/proto/user"
"multi-grpc-demo/app/proto/notification"
)

// App is the main application structure
type App struct{}

// Init initializes the application
func (app *App) Init() {
// Create and register the User Service controller (internal server)
userService := NewUserServiceController()
engine.RegisterGrpcController(userService, func(server *grpc.Server) {
user.RegisterUserServiceServer(server, userService)
})

// Create and register the Notification Service controller (public server)
notificationService := NewNotificationServiceController()
engine.RegisterGrpcController(notificationService, func(server *grpc.Server) {
notification.RegisterNotificationServiceServer(server, notificationService)
})
}

Step 5: Running and Testing the Multiple gRPC Servers

Now, let's run the application:

go run main.go runserver

Gonyx will start two gRPC servers:

  • One on port 7777 (user_server) with the User Service
  • One on port 7778 (public_server) with the Notification Service

You can test these servers using a gRPC client like grpcurl:

Testing the User Service (Internal Server)

# List services on the user server
grpcurl -plaintext localhost:7777 list

# Create a user
grpcurl -plaintext -d '{"username":"john_doe", "email":"john@example.com", "full_name":"John Doe"}' \
localhost:7777 user.UserService/CreateUser

# Get a user (replace USER_ID with the actual ID from the create response)
grpcurl -plaintext -d '{"user_id":"USER_ID"}' \
localhost:7777 user.UserService/GetUser

Testing the Notification Service (Public Server)

# List services on the public server
grpcurl -plaintext localhost:7778 list

# Send a notification
grpcurl -plaintext -d '{"title":"Welcome", "message":"Welcome to our system!", "recipient_ids":["USER_ID"]}' \
localhost:7778 notification.NotificationService/SendNotification

# Get notifications for a user
grpcurl -plaintext -d '{"user_id":"USER_ID", "include_read":true, "limit":10, "offset":0}' \
localhost:7778 notification.NotificationService/GetUserNotifications

Understanding Service Assignment

In Gonyx, gRPC services are assigned to specific servers using the GetServerNames() method in each controller. This method returns an array of server names that should handle the service:

// Assigns to the user_server only
func (ctrl *UserServiceController) GetServerNames() []string {
return []string{"user_server"}
}

// Assigns to the public_server only
func (ctrl *NotificationServiceController) GetServerNames() []string {
return []string{"public_server"}
}

Multi-Server Assignment

You can assign a service to multiple servers by listing all target servers in the returned array:

// Assign to both servers
func (ctrl *SomeController) GetServerNames() []string {
return []string{"user_server", "public_server"}
}

This way, the same gRPC service will be available on both servers.

Advanced Configurations

Server-Specific Options

Each gRPC server in your configuration can have its own settings:

  • Message Size Limits: Set different limits for internal vs. external traffic
  • Reflection: Enable it for development but disable for production
  • Authentication: Implement different auth strategies per server
  • Network Interfaces: Bind internal services to localhost and public ones to all interfaces

Environment-Specific Configurations

For different environments, you can use different configuration files:

  • configs/dev/protobuf.json for development
  • configs/prod/protobuf.json for production

Each can have different server settings optimized for that environment.

Conclusion

Running multiple gRPC servers in parallel with Gonyx provides powerful separation of concerns. By using the GetServerNames() method, you can explicitly control which servers handle each service.

This approach allows you to:

  • Separate internal from external-facing services
  • Apply different security and performance settings to different server groups
  • Run different services on different ports and network interfaces
  • Scale and manage services independently

For production environments, you can further enhance this setup with proper authentication, TLS encryption, and load balancing across multiple instances.