When we talk about microservices we think about various little services working together, each one independent that can be implemented in separated. Build a microservice in Go probably is not your first option when we are working in a Spring Cloud architecture where the most participating applications are based in Java. But one of the strongest characteristic of microservice architecture is that you could build different services in different languages, where you could use a more performative language to solve a specific problem.
Bellow we'll see how to implement a microservice built in GoLang into a Spring Cloud architecture, so we will use service discovery, an API Gateway and securize the application with OAuth using a Keycloak server as provider.
The problem
You developped an API for an internal system for a dental clinic, with this API the admin employees can register, read, update and delete data from patients, dentists and
schedulings. The clinic already has a system to manage invoices for their patients - also internal, however there's no integration and comunication between the schedulling
service and invoicing service API.
We need make this integration and comunication between them in Spring Cloud environmnent.
To Do List
- Register scheduling service into Eureka Server.
- Calls the invoice service each time that a new scheduling has been made or occur an update.
- Implement auth througth the API Gateway and authenticate each request, including from invoice service for scheduling service.
Getting started
To follow this article, please clone my GitHub repository with the start code branch (I'm assuming that you already know well Java with Spring Framework and GoLang).
This branch contains scheduling-service (GoLang), invoice-service (Java), Spring Eureka Server and Spring Cloud Gateway
git clone -b starter https://github.com/ronilsonalves/GoLang-in-a-spring-cloud-architecture.git
cd scheduling-service
go mod download
Registering and deregistering our microservice in Spring Eureka
To implement service discovery in our GoLang microservice we will use GoKit, a toolkit for microservices that provides support to auth, log, service discovery, tracing and more.
For this starter code the mod already installed, you can skip this step
go get github.com/go-kit/kit
We need to buid a fargo instance containing all information about our Go Lang microservice before we can register it on Eureka
func buildFargoInstanceBody(appName, status string) *fargo.Instance {
ipAddr, err := externalIP()
if err != nil {
fmt.Println(err)
}
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
fmt.Println(err)
port = 9000
}
return &fargo.Instance{
InstanceId: ipAddr + ":" + appName + ":" + os.Getenv("PORT"),
HostName: "localhost",
App: strings.ToUpper(appName),
IPAddr: ipAddr,
VipAddress: appName,
SecureVipAddress: appName,
Status: fargo.StatusType(status),
Overriddenstatus: "UNKNOWN",
Port: port,
PortEnabled: true,
SecurePort: 8443,
SecurePortEnabled: false,
HomePageUrl: "http://localhost:" + os.Getenv("PORT") + os.Getenv("BASE_PATH"),
StatusPageUrl: "http://localhost:" + os.Getenv("PORT") + "/status",
HealthCheckUrl: "http://localhost:" + os.Getenv("PORT") + "/health",
CountryId: 0,
DataCenterInfo: fargo.DataCenterInfo{
Name: "MyOwn", Class: "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo",
},
LeaseInfo: fargo.LeaseInfo{},
Metadata: fargo.InstanceMetadata{},
UniqueID: nil,
}
}
// BuildFargoInstance build a Fargo Instance and return eureka.Registrar
func BuildFargoInstance() eureka.Registrar {
eurekaAddr := os.Getenv("EUREKA_SERVER_URL")
if eurekaAddr == "" {
fmt.Println("EUREKA_SERVER_URL is not set")
}
logger := log.NewLogfmtLogger(os.Stderr)
logger = log.With(logger, "ts", log.DefaultTimestamp)
var fargoConfig fargo.Config
fargoConfig.Eureka.ServiceUrls = []string{eurekaAddr}
fargoConfig.Eureka.PollIntervalSeconds = 1
fargoConnection := fargo.NewConnFromConfig(fargoConfig)
fInstance := buildFargoInstanceBody("scheduling-service", "UP")
return *eureka.NewRegistrar(&fargoConnection, fInstance, log.With(logger, "component", "registrar"))
}
With eureka.Registrar returned we can use it to register and deregister our microservice, for that at main function we need only atribute the
return of BuildFargoInstance() to a var and call Register() method.
eurekaRegister := sd.BuildFargoInstance()
eurekaRegister.Register()
At this point we've registered successfully our GoLang microservice in Eureka Server, the module provided by GoKit automatically sends heartbeats to
our Eureka Server instance while our microservice is running, but to update and deregister our microservice in case of his instance is terminated or killed
we need call Deregister() method, for that we will build a channel to monitor the application status in our main function.
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt)
// contains filtered fields or functions in main function
go func() {
select {
case signal := <-c:
_ = signal
time.Sleep(4 * time.Second)
eurekaRegister.Deregister()
os.Exit(1)
}
}()
We've successfully integrated our GoLang microservice into Spring Eureka. The first item of our to do list is done.
Calling invoice service through a RabbitMQ queue
To call our invoicing service each time an event occur in scheduling service we will use a RabbitMQ queue, each event
will send a message to our queue and in invoice service we will implement a listener for this queue and consume this message.
First all, in our scheduling service we need to implement a publisher, for that we will use a go rabbitmq client. Again,
this starter code already has a module, so the following step is not necessary unless you pretend to use another client or
building a new project.
go get github.com/hadihammurabi/go-rabbitmq
So, we need to connect to our RabbitMQ instance and create a channel to publish our messages.
// ConnectRabbitMQ connect and setup RabbitMQ channel and queue
func ConnectRabbitMQ(urlConn, name string) (gorabbitmq.MQ, error) {
mq, err := gorabbitmq.New(urlConn)
failOnError(err, "Failed to create a MQ")
err = mq.Exchange().
WithName(name).
WithType(exchange.TypeDirect).
Declare()
failOnError(err, "Failed to create a channel")
q, err := mq.Queue().
WithName(name).
Declare()
failOnError(err, "Failed to create a queue")
err = q.Binding().
WithExchange(name).
Bind()
failOnError(err, "Failed to bind queue")
return *mq, nil
}
In our service we will publish a message to our queue each time a new event is created, for that we need to implement a method to publish a message
to our queue.
// PublishMessage - send a msg to RabbitMQ queue when an appointment is made or updated
func PublishMessage(a domain.AppointmentDTO) {
mq, err := ConnectRabbitMQ(os.Getenv("RABBIT_MQ_URL_CONN"), "appointment-service")
log.Println(err)
body, _ := json.Marshal(a)
err = mq.Publish(&gorabbitmq.MQConfigPublish{
RoutingKey: mq.Queue().Name,
Message: amqpi.Publishing{
ContentType: "application/json",
Body: body,
},
})
defer mq.Close()
}
Now we need to call this method each time a new event is created or updated, for that we need call it in our Create() and Update() methods in our service.
func (s *service) Create(a domain.Appointment)(domain.AppointmentDTO, error) {
// contains filtered fields or functions
if ok {
amqp.PublishMessage(apSaved)
return apSaved, nil
}
}
func (s *service) Update(id int, a domain.Appointment)(domain.AppointmentDTO, error) {
// contains filtered fields or functions
amqp.PublishMessage(response)
return response, nil
}
We already have our publisher implemented, now we need to implement our listener/consumer in our invoice service. As business rule, we need create an invoice
each time a new scheduling is made and it is updated we need update the invoice.
First we will configure our RabbitMQ connection and create a channel to consume our messages.
@Configuration
public class RabbitMQSenderConfig {
@Value("${queue.appointment-service.name}")
private String appointmentServiceQueue;
@Bean
public Queue appointmentQueue() {
return new Queue(this.appointmentServiceQueue,false);
}
}
Also we need setup a message converter to convert our message to a Java object. As in this project we are using Brazilian Datetime format("dd/MM/yyyy HH:mm"), we need to
configure a customObjectMapper to avoid an exception while trying to convert Datetime fields. For that we will create a class.
@RequiredArgsConstructor
public class CustomLocalDateTimeObjectMapper {
private final ObjectMapper customObjectMapper;
public ObjectMapper getCustomLocalDateTimeObjectMapper() {
JavaTimeModule module = new JavaTimeModule();
this.customObjectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
this.customObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
LocalDateTimeDeserializer localDateTimeDeserializer =
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"));
module.addDeserializer(LocalDateTime.class, localDateTimeDeserializer);
this.customObjectMapper.registerModule(module);
return this.customObjectMapper;
}
}
And now we need to configure our RabbitMQ template to use our customObjectMapper.
@Configuration
public class RabbitTemplateConfig {
@Bean
public Jackson2JsonMessageConverter producerJackson2MessageConverter() {
CustomLocalDateTimeObjectMapper customLocalDateTimeObjectMapper =
new CustomLocalDateTimeObjectMapper(new ObjectMapper());
return new Jackson2JsonMessageConverter(customLocalDateTimeObjectMapper.getCustomLocalDateTimeObjectMapper());
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(producerJackson2MessageConverter());
return rabbitTemplate;
}
}
Now we need to create a listener to consume our messages.
@RequiredArgsConstructor
@Component
public class AppointmentListener {
private final Logger logger = Logger.getLogger(AppointmentListener.class.getName());
private final InvoiceServiceImpl invoiceService;
@RabbitListener(queues = {"${queue.appointment-service.name}"})
public void receiveMessage(Appointment appointment) {
logger.log(Level.INFO,"Message received from RabbitMQ: "+appointment);
List<Invoice> invoices =
invoiceService.listAll().stream().filter(invoice -> Objects.equals(invoice.getAppointmentId(), appointment.id())).toList();
if (invoices.isEmpty()) {
invoiceService.save(appointment);
} else {
invoiceService.update(invoices.get(0),appointment);
}
}
}
In our listener we verify if there is an invoice for the appointment, if there is not we create a new invoice, if there is we update the invoice.
And that point we have our invoice service listening to our appointment service and creating or updating invoices. Second item of our todo list is done.
But comunication not yet, we will finish after update our authentication flow.
Securizing our scheduling service with Keycloak as Identity Provider.
This is the third item of our todo list, we need to secure our scheduling service with Keycloak as Identity Provider. As we are using Spring Gateway and Spring Security
we can setup a token relay and instead of using a simple token we can use the JWT token provided by Keycloak and validate it in our scheduling service if the user has the
authorized role.
The Keycloak realm configuration is available in the GitHub repository.
First we need to configure our Spring Gateway to relay the token to our scheduling service. We can setup it in our application.yml file. Note: the application.yml file already
contains the configuration for token relay, Keycloak, so you can skip this step if you are using the same file from the GitHub repository.
## contains filtered fields
cloud:
gateway:
routes:
- id: invoice-service
uri: lb://invoice-service
predicates:
- Path=/invoices/**
- id: scheduling-service
uri: lb://scheduling-service
predicates:
- Path=/api/v1/**
default-filters:
- TokenRelay
- LogFilter
## contains filtered fields
In our SecurityConfig.java we have configured that any request must be authenticated, if no, the user will be redirected to Keycloak login page.
@Configuration
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity httpSecurity) {
httpSecurity
.authorizeExchange()
.pathMatchers("/actuator/**").permitAll()
.anyExchange().authenticated()
.and()
.oauth2Login()
.and().csrf().disable();
return httpSecurity.build();
}
}
In our scheduling service we need to change the authentication to use the JWT token provided by Keycloak and relayed by our gateway. We must create a middleware to validate the token received.
type Claims struct {
RealmAccess roles `json:"realm_access,omitempty"`
JTI string `json:"jti,omitempty"`
}
type roles struct {
Roles []string `json:"roles,omitempty"`
}
var RealmConfigURL = os.Getenv("REALM_CONFIG_URL")
var clientID = os.Getenv("CLIENT_ID")
var authorizedRole = "ADMIN"
func IsAuthorizedJWT() gin.HandlerFunc {
return func(c *gin.Context) {
rawAccessToken := strings.Replace(c.GetHeader("Authorization"), "Bearer", "", 1)
trans := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
client := &http.Client{
Timeout: time.Duration(6000) * time.Second,
Transport: trans,
}
ctx := oidc.ClientContext(context.Background(), client)
provider, err := oidc.NewProvider(ctx, RealmConfigURL)
if err != nil {
authorizationFailed("an authorization error occurred while getting the provider: "+err.Error(), c)
return
}
oidcConfig := &oidc.Config{
ClientID: clientID,
}
verifier := provider.Verifier(oidcConfig)
idToken, err := verifier.Verify(ctx, rawAccessToken)
if err != nil {
authorizationFailed("an authorization error occurred while verifying the token: "+err.Error(), c)
return
}
var IDTokenClaims Claims
if err := idToken.Claims(&IDTokenClaims); err != nil {
authorizationFailed("An error occurred while extracting claims: "+err.Error(), c)
return
}
userAccessRoles := IDTokenClaims.RealmAccess.Roles
for _, userRole := range userAccessRoles {
if userRole == authorizedRole {
c.Next()
return
}
}
authorizationFailed("The user has no permission to access this API", c)
}
}
func authorizationFailed(message string, c *gin.Context) {
web.BadResponse(c, http.StatusUnauthorized, "ERROR", message)
return
}
So in our main.go we need to updated the authentication middleware to use the JWT token in our router.
func main() {
// contains filtered fields or functions
r.Use(IsAuthorizedJWT())
// contains filtered fields or functions
}
Now, the access to our scheduling service is secured througth our API Gateway. The third item of our todo list is almost done.
If an user try to create an invoice manually, he must provide an appointment ID and a price in request body, with ID provided our invoice service will request the scheduling service
to get the appointment data and validate if the appointment exists and after all create the invoice, to make this request we will use a Feign client and it must be authenticated.
We need handle the client authentication.
@Configuration
@RequiredArgsConstructor
public class FeignConfiguration {
private static final String KEYCLOAK_REGISTRATION_ID = "keycloak-registration";
private final OAuth2AuthorizedClientService clientService;
private final ClientRegistrationRepository registrationRepository;
@Bean
public RequestInterceptor requestInterceptor() {
ClientRegistration clientRegistration = registrationRepository.findByRegistrationId(KEYCLOAK_REGISTRATION_ID);
OAAuth2ClientCredentialsFeignManager credentialsFeignMananger =
new OAAuth2ClientCredentialsFeignManager(authorizedClientManager(),clientRegistration);
return requestInterceptor -> {
requestInterceptor.header("Authorization", "Bearer " + credentialsFeignMananger.getAccessToken());
};
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager () {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
.builder()
.clientCredentials()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrationRepository, clientService);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
Since our invoice service may request data from appointments and consequently clients and dentists related to theses appointments
using feign clients. As all endpoints of our scheduling API require authentication, we need to setup a role to the user from our keycloak invoice service client.
NOTE: In the keycloak realm configuration provided at the GitHub repository, the configuration already has been made, you can skip this step.
The keycloak version used for this tutorial is the 20.0.2 running in a docker container
Step 1: Access the admin console from your keycloak server
Step 2: Access the backend-services client. In Settings, go to Capability config and check the Service accounts roles option
Step 3: Access the Realm roles. Create a new role called ADMIN (the same that we are mapping in our microservices)
Step 4: Access the Service account roles tab from backend-services client and assign the ADMIN role to it
Don't forget to create a user in your keycloak realm and assign the ADMIN role to it.
And done, we have successfully secured our scheduling service and integrated it into Spring Cloud architecture.
I hope that this tutorial was useful to you and if you have any questions or suggestions, please let me know. Thanks for reading.
Top comments (0)