A React Frontend With Go/Gin/Gorm Backend

The ReactAndGo project is used to compare a single page application frontend based on React and a Rest backend based on Go to Angular frontends and Spring Boot/Java backends. 

The goal of the project is to send out notifications to car drivers if the gas price falls below their target price. The gas prices are imported from a provider via MQTT messaging and stored in the database. For development, two test messages are provided that are sent to an Apache Artemis server to be processed in the project. The Apache Artemis server can be run as a Docker image, and the commands to download and run the image can be found in the ‘docker-artemis.sh‘ file. As a database, Postgresql is used, and it can be run as a Docker image too. The commands can be found in the ‘docker-postgres.sh‘ file.

Architecture

The system architecture looks like this:

The React frontend uses the Rest interface that the Gin framework provides to communicate with the backend. The Apache Artemis Messaging Server is used in development to receive and send back the gas price test messages that are handled with the Paho-MQTT library. In production, the provider sends the MQTT messages. The Gorm framework is used to store the data in Postgresql. A push notification display is used to show the notification from the frontend if the target prices are reached.

The open-source projects using Go have more of a domain-driven architecture that splits the code for each domain into packages. For the ReactAndGo project, the domain-driven architecture is combined with a layered architecture to structure the code.

base controller

The common BaseController is needed to manage the routes and security of the application. The architecture is split between the gas station domain, the push notification domain, and the application user domain. The Rest request and response handling is in its own layer that includes the Rest client for the gas station import. The service layer contains the logic, database access, and other helper functions. Domain-independent functions like Cron Jobs, Jwt token handling, and message handling are implemented in separate packages that are in a utility role.

Notifications From React Frontend to Go/Gin/Gorm Backend

The ReactAndGo project is used to show how to display notifications with periodic requests to the backend and how to process rest requests in the backend in controllers and repositories.

React Frontend

In the front end, a dedicated worker is started after login that manages the notifications. The initWebWorker(...) function of the LoginModal.tsx starts the worker and handles the tokens:

  const initWebWorker = async (userResponse: UserResponse) => {
    let result = null;
    if (!globalWebWorkerRefState) {
      const worker = new Worker(new URL('../webpush/dedicated-worker.js', import.meta.url));
      if (!!worker) {
        worker.addEventListener('message', (event: MessageEvent) => {
          //console.log(event.data);
          if (!!event?.data?.Token && event?.data.Token?.length > 10) {
            setGlobalJwtToken(event.data.Token);
          }
        });
        worker.postMessage({ jwtToken: userResponse.Token, newNotificationUrl: `/usernotification/new/${userResponse.Uuid}` } as MsgData);
        setGlobalWebWorkerRefState(worker);
        result = worker;
      }
    } else {
      globalWebWorkerRefState.postMessage({ jwtToken: userResponse.Token, newNotificationUrl: `/usernotification/new/${userResponse.Uuid}` } as MsgData);
      result = globalWebWorkerRefState;
    }
    return result;
  };

The React frontend uses the Recoil library for state management and checks if the globalWebWorkerRefState exists. If not, the worker in dedicated-worker.js gets created and the event listener for the Jwt tokens is created. The Jwt token is stored in a Recoil state to be used in all rest requests. Then the postMessage(...) method of the worker is called to start the requests for the notifications. Then the worker is stored in the globalWebWorkerRefState and the worker is returned.

The worker is developed in the dedicated-worker.ts file. The worker is needed as .js file. To have the help of Typescript, the worker is developed in Typescript and then turned into Javascript in the Typescript Playground. That saves a lot of time for me. The refreshToken(...) function of the worker refreshes the Jwt tokens:

interface UserResponse {
  Token?: string
  Message?: string
}

let jwtToken = '';
let tokenIntervalRef: ReturnType<typeof setInterval>;
const refreshToken = (myToken: string) => {
  if (!!tokenIntervalRef) {
    clearInterval(tokenIntervalRef);
  }
  jwtToken = myToken;
  if (!!jwtToken && jwtToken.length > 10) {
    tokenIntervalRef = setInterval(() => {
      const requestOptions = {
        method: 'GET',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jwtToken}` },
      };
      fetch('/appuser/refreshtoken', requestOptions).then(response => response.json() as UserResponse)
        .then(result => {
        if ((!result.Message && !!result.Token && result.Token.length > 10)) {
          //console.log('Token refreshed.');
          jwtToken = result.Token;
          /* eslint-disable-next-line no-restricted-globals */
          self.postMessage(result);
        } else {
          jwtToken = '';
          clearInterval(tokenIntervalRef);
        }
      });
    }, 45000);
  }
}

The refreshToken(...) function first checks if another token interval has been started and stops it. Then the token is assigned and checked. If it passes the check a new interval is started to refresh the token every 45 seconds. The requestOptions are created with the token in the Authorization header field. Then the new token is retrieved with fetch(...) , and the response is checked, the token is set, and it is posted to the EventListener in the LoginModal.tsx. If the Jwt token has not been received, the interval is stopped, and the jwtToken is set to an empty string.

The Eventlistener of the worker receives the token message and processes it as follows:

interface MsgData {
  jwtToken: string;
  newNotificationUrl: string;
}

let notificationIntervalRef: ReturnType<typeof setInterval>;
/* eslint-disable-next-line no-restricted-globals */
self.addEventListener('message', (event: MessageEvent) => {
  const msgData = event.data as MsgData;
  refreshToken(msgData.jwtToken);  
  if (!!notificationIntervalRef) {
    clearInterval(notificationIntervalRef);
  }
  notificationIntervalRef = setInterval(() => {
    if (!jwtToken) {
      clearInterval(notificationIntervalRef);
    }
    const requestOptions = {
      method: 'GET',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jwtToken}` },
    };
    /* eslint-disable-next-line no-restricted-globals */
    self.fetch(msgData.newNotificationUrl, requestOptions).then(result => result.json()).then(resultJson => {
      if (!!resultJson && resultJson?.length > 0) {
        /* eslint-disable-next-line no-restricted-globals */
        self.postMessage(resultJson);
        //Notification
        //console.log(Notification.permission);
        if (Notification.permission === 'granted') { 
          if(resultJson?.length > 0 && resultJson[0]?.Message?.length > 1 && resultJson[0]?.Title?.length > 1) {            
            for(let value of resultJson) {
            new Notification(value?.Title, {body: value?.Message});
            }
          }                
        }
      }
    });
  }, 60000);
});

The addEventListener(...) method handles the MessageEvent messages with the MsgData. The jwtToken of the MsgData is used to start the refreshToken(...) function. Then it is checked to see if a notification interval has been started, and if so, it is stopped. Then a new interval is created that checks for new target-matching gas prices every 60 seconds. The jwtToken is checked, and if the check fails, the interval is stopped. Then the requestOptions are created with the Jwt token in the Authorization header field. Then fetch(...) is used to retrieve the new matching gas price updates. Then the result JSON is checked and posted back to the EventListener in the LoginModal.tsx. With Notification.permission the user gets asked for permission to send notifications, and granted means he agreed. The data for the notification is checked, and the notification is sent with new Notification(...).

Backend

To handle the frontend requests, the Go backend uses the Gin framework. The Gin framework provides the needed functions to handle Rest requests, like a router, context (url related stuff), TLS support, and JSON handling. The route is defined in the basecontroller.go

func Start(embeddedFiles fs.FS) {
	router := gin.Default()
        ...
	router.GET("/usernotification/new/:useruuid", token.CheckToken, getNewUserNotifications)
        ...
	router.GET("/usernotification/current/:useruuid", token.CheckToken, getCurrentUserNotifications)
	router.StaticFS("/public", http.FS(embeddedFiles))
	router.NoRoute(func(c *gin.Context) { c.Redirect(http.StatusTemporaryRedirect, "/public") })
	absolutePathKeyFile := strings.TrimSpace(os.Getenv("ABSOLUTE_PATH_KEY_FILE"))
	absolutePathCertFile := strings.TrimSpace(os.Getenv("ABSOLUTE_PATH_CERT_FILE"))
	myPort := strings.TrimSpace(os.Getenv("PORT"))
	if len(absolutePathCertFile) < 2 || len(absolutePathKeyFile) < 2 || len(myPort) < 2 {
		router.Run() // listen and serve on 0.0.0.0:3000
	} else {
		log.Fatal(router.RunTLS(":"+myPort, absolutePathCertFile, absolutePathKeyFile))
	}
}

The Start function gets the embedded files for the /public directory with the static frontend files. The line:

router.GET("/usernotification/new/:useruuid", token.CheckToken, getNewUserNotifications)

Creates the route /usernotification/new/:useruuid with the useruuid as parameter. The CheckToken function in the token.go file handles the Jwt Token validation. The getNewUserNotifications function in the in the uncontroller.go handles the requests.

The getNewUserNotifications(...) function:

func getNewUserNotifications(c *gin.Context) {
	userUuid := c.Param("useruuid")
	myNotifications := notification.LoadNotifications(userUuid, true)
	c.JSON(http.StatusOK, mapToUnResponses(myNotifications))
}

...

func mapToUnResponses(myNotifications []unmodel.UserNotification) []unbody.UnResponse {
   var unResponses []unbody.UnResponse
   for _, myNotification := range myNotifications {
      unResponse := unbody.UnResponse{
         Timestamp: myNotification.Timestamp, UserUuid: myNotification.UserUuid, Title: myNotification.Title, 
            Message: myNotification.Message, DataJson: myNotification.DataJson,
      }
      unResponses = append(unResponses, unResponse)
   }
   return unResponses
}

The getNewUserNotifications(…) function uses the Gin context to get the path parameter useruuid and then calls the LoadNotifications(…) function of the repository with it. The result is turned into UserNotifications with the mapToUnResponses(…) function, which sends only the data needed by the frontend. The Gin context is used to return the HTTP status OK and to marshal the UserNotifications to JSON.

The function LoadNotifications(...) is in the unrepo.go file and loads the notifications from the database with the Gorm framework:

func LoadNotifications(userUuid string, newNotifications bool) []unmodel.UserNotification {
   var userNotifications []unmodel.UserNotification
   if newNotifications {
     database.DB.Transaction(func(tx *gorm.DB) error {
        tx.Where("user_uuid = ? and notification_send = ?", userUuid, !newNotifications)
           .Order("timestamp desc").Find(&userNotifications)
	for _, userNotification := range userNotifications {
	   userNotification.NotificationSend = true
	   tx.Save(&userNotification)
	}
	return nil
     })
   } else {
      database.DB.Transaction(func(tx *gorm.DB) error {
         tx.Where("user_uuid = ?", userUuid).Order("timestamp desc").Find(&userNotifications)
         var myUserNotifications []unmodel.UserNotification
         for index, userNotification := range userNotifications {
            if index < 10 {
	       myUserNotifications = append(myUserNotifications, userNotification)
	       continue
  	    }
	    tx.Delete(&userNotification)
          }
          userNotifications = myUserNotifications
          return nil
      })
    }
    return userNotifications
}

The LoadNotifications(...) function checks if only new notifications are requested. Then a database transaction is created, and the new UserNotifications (notification.go) of the user file are selected, ordered newest first. The send flag is set to true to mark them as no longer new, and the UserNotifications are saved to the database. The transaction is then closed, and the notifications are returned.

If the current notifications are requested, a database transaction is opened, and the UserNotifications of the user are selected, ordered newest first. The first 10 notifications of the user are appended to the myUserNotification slice, and the others are deleted from the database. Then the transaction is closed, and the notifications are returned.

Conclusion

This is the first React frontend for me, and I share my experience developing this frontend. React is a much smaller library than the Angular Framework and needs more extra libraries like Recoil for state management. Features like interval are included in the Angular RxJs library. React has much fewer features and needs more additional libraries to achieve the same result. Angular is better for use cases where the frontend needs more than basic features. React has the advantage of simple frontends. A React frontend that grows to medium size will need more design and architecture work to be comparable to an Angular solution and might take more effort during development due to its less opinionated design.

My impression is: React is the kitplane that you have to assemble yourself. Angular is the plane that is rolled out of the factory. 

The Go/Gin/Gorm backend works well. The Go language is much simpler than Java and makes reading it fast. Go can be learned in a relatively short amount of time and has strict types and a multi-threading concept that Project Loom tries to add to Java. The Gin framework offers the features needed to develop the controllers and can be compared to the Spring Boot framework in features and ease of development. The Gorm framework offers the features needed to develop the repositories for database access and management and can be compared to the Spring Boot framework in terms of features and ease of development.

The selling point of Go is its lower memory consumption and fast startup because it compiles to a binary and does not need a virtual machine. Go and Java have garbage collection. Java can catch up with Project Graal on startup time, but the medium- to large-sized examples have to be available and analyzed first for memory consumption. A decision can be based on developer skills, the amount of memory saved, and the expected future of Project Graal.


Source link