A strategic foray to Spring components, cloud and key points to it. (Update 2)

Yiğit İrez
18 min readMay 15, 2021

--

If we want to use Spring components for a microservices architecture, just understanding devops components is not enough. Let’s dive into Spring a little bit.

Note: This document will be updated continuously to cover all Spring components necessary for a microservices architecture.

Spring Boot/Core and DI

First of all, I’m going to be using Intellij IDEA and the code I’m using for tests are going to be updated here:

Now lets start by creating a simple maven project with Spring Boot. Use the below bit as base, fill the next screen as whatever you want, Then choose the below components. Finish up by choosing a project name and location.

Now we are ready to understand each component,

public class Test1Application {

public static void main(String[] args) {
ApplicationContext appContext = SpringApplication.run(Test1Application.class, args);

It starts with initializing an ApplicationContext(AC from now on) object. AC can load bean definitions, initializes objects, wires them together, handle strings from a properties file and publish app events to listeners.

For now lets focus on the first 3 bits.

Create any 2 simple classes with the below key points,

public class Alien {

int age=15;
Laptop laptop;
public void code() {
System.out.println("Test1");
}
public void control() {
laptop.compile();
}
}
public class Laptop {
public void compile() {
System.out.println("compiled");
}
}

So without spring, we would have to init these 2 classes to be able to use them from our Main method. Also notice that Alien class has a reference to Laptop class which is uninitialized. Under older circumstances this would need to be initialized in the constructor and you would need to consider null checks, etc.

Now add the following annotation on top of both classes

@Component
public class Alien
...
@Component
public class Laptop
...

This annotation tells spring senpai to notice this class but we also need to make it available as a bean via AC to be used when needed.

For that we create a spring.xml in scr-main-resources with the following content,

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="alien" class="com.springtests.yigit.test1.Alien" scope="singleton"></bean>


</beans>

We also need to make Laptop instance readily available to Alien class with the following annotation right on top of Laptop laptop like below,

@Autowired
Laptop laptop;

Great, so spring knows what to do but how do we use these classes without initing in main. We can do so by asking the AC to give us our bean.

Alien obj = appContext.getBean(Alien.class);
obj.code();

Run this via the readily available config and see the sys output.

All is well, but what happens when you ask for multiple instances of the same class. Notice, Alien has a an age property in it set to 15. In main do something like the following and run again,

Alien alien = appContext.getBean(Alien.class);
alien.code();
alien.control();
alien.age = 2392
System.out.println("alien age:" + alien.age);
Alien alien2 = appContext.getBean(Alien.class);
alien2.code();
alien2.control();
System.out.println("alien age:" + alien2.age);

Notice the age value doesn’t change. This is because Spring Container has init an Alien object for you and will return that every time you ask for an Alien. Thats why all spring beans are called a singleton. So what can we do if we want a different object each time. We can change the scope value of our spring.xml to scope=”prototype”. Run it again and the age value should be different.

Add a constructor to the alien class like below.

public Alien(){
System.out.println("Alien obj created");
}

Notice how the object is created both in singleton and protoype scope. Now comment all code after AC init and see that Alien bean is still created. AC makes your stated beans ready for use even if you use them or not.

Moving on, we had a property in Alien class called age. That is, normally it would have been a property. Make the field private and add a getter and setter.

Now since we made it a property, we can modify our spring.xml to handle our object init values like below. Rerun the code (change age prop usage to use get/set in main) to see if we haven’t entered anything, age value will be 100.

<bean id="alien" class="com.springtests.yigit.test1.Alien" scope="prototype">
<property name="age" value="100"></property>
</bean>

Great, but what if we want to handle a Laptop object the same way. Lets make laptop field in Alien private and add getter and setter to it. When we run we will get a null pointer exception because there is no value set to it. We would need to set it via the spring.xml like below.

<bean id="alien" class="com.springtests.yigit.test1.Alien" scope="prototype">
<property name="age" value="100"></property>
<property name="laptop" ref="laptop"></property>
</bean>
<bean class="com.springtests.yigit.test1.Laptop" id="laptop">
</bean>

We define laptop as a bean as usual, then set the property of the laptop field in Alien class but since the value field cannot be a primitive value and must be an object, we use ref to pass a laptop type.

So thats great and all, though perhaps we should set the age value in the constructor. We can do so via the spring.xml file again. Change the alien bean definition like below.

<bean id="alien" class="com.springtests.yigit.test1.Alien" scope="prototype">
<constructor-arg value="100"></constructor-arg>
<property name="laptop" ref="laptop"></property>
</bean>

When you save this there will be an error saying no such constructor exists so lets fix that as well. In the Alien class change the default constructor to;

public Alien(int age) {
this.age = age;
System.out.println("Alien obj created");
}

Next up, let’s talk about a scenario where computer type can be a laptop or desktop. To start, let’s create an interface Computer, a Desktop class which is a duplicate of the Laptop class and use implements Computer on both like below.

public interface Computer {
void compile();
}

We also need to change the property type and related getter setter of laptop to Computer interface in the Alien class. Also, change the spring.xml like so;

<bean id="alien" class="com.springtests.yigit.test1.Alien"
scope="prototype" autowire="byType" >
<constructor-arg value="100"></constructor-arg>
</bean>
<bean class="com.springtests.yigit.test1.Laptop" id="laptop">
</bean>
<bean class="com.springtests.yigit.test1.Desktop" id="desktop">
</bean>

Notice there is no property value anymore and we added an autowire property. This property is set to byType right now and if you run it, it should throw an error saying expected single matching bean but found 2: laptop,desktop. We fix that like by adding primary=”true” attr to laptop bean def.

...primary="true">

Spring MVC

From here on, lets continue with another project. If you want, code link is below;

Otherwise project creation is the same, except we select Spring Web as basis for our app.

We should get everything except webapps->index.jsp which we should be creating with any jsp. There should also be a ready spring boot run config

Next we should add a controller called HomeController with the following details. Note that controllers are generally named after the page thats being worked on so since we will be working on the Home page, it’s named HomeController. Notice we haven’t implemented any interface or done anything else to make this a controller except for the annotation.

@Controller
public class HomeController {
@RequestMapping("/")
public String home(){
System.out.println("home page req");
return "index.jsp";
}
}

We want the users requesting our home page to land on the home method of the controller. So we use RequestMapping annotation with the / value. Additionally, when a user requests our home page, we want the dispatcher servlet to know where it’s going to send the user to so we return the page name back.

Note: If you don’t see the below dependency in your pom.xml file, you need to manually add it.

<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<scope>compile</scope>
</dependency>

/Note

Now lets run and check localhost:8080

yay

Great, now lets get some user input. Lets add a form to our jsp page.

<html>
<head><title>Yay</title></head>
<body>
'ello

<form action="sendVals">
Enter text : <input type="text" name="text1"><br>
Enter num : <input type="number" name="num1"><br>
<input type="submit">
</form>
</body>
</html>

We need actions to drop into our controller. We make use of the sendVals action declaration and add it to our HomeController as a method,

@RequestMapping("sendVals")
public ModelAndView sendVals(@RequestParam("text1") String textVal,
@RequestParam("num1") String numVal){
ModelAndView mv=new ModelAndView("values.jsp");
mv.addObject("resText",textVal);
mv.addObject("resNum",numVal);

return mv;
}

So what’s happening here is we read text1 and num1 as request params from spring, create a new ModelAndView object and place the values into the session with attribute values we will be reading from the redirected page. We also set the view name to the page we want to go to. The ModelAndView object can be returned and spring will handle the rest. We will be referencing the session values from the values page like below;

<html>
<head><title>values</title></head>
<body>
hi
Entered text val : ${resText}<br>
Enter num val : ${resNum}<br>

</body>
</html>
yay

Now that we have the basics working, let’s remove the dependency on .jsp for our views since we reference by name. First we should move all jsp’s to a new folder under webapps called views.

Remove all .jsp values in the controller file. It should just be the name like return “index”; So when we run like this we will get 404. To fix it, lets open the application.properties file. There are many possible values possible here and you can see them all from here. We will be using the prefix (where our views are) and suffix (what our view file extensions are)

spring.mvc.view.prefix= /views/
spring.mvc.view.suffix= .jsp

Run it and see that it works.

Now lets look at carrying a model around. Create an Alien class under package model with string id and name properties, along with their getter/setters. In our index.jsp lets add another form.

<form action="alienate">
Enter id : <input type="number" name="id"><br>
Enter name : <input type="text" name="name"><br>
<input type="submit">
</form>

And in our HomeController, lets add another method. Here we essentially stuff our input to a new Alien object which we add to the model. After redirecting to the alienated page we use the model data from the attribute alien we sent.

@RequestMapping("alienate")
public String alienate(@RequestParam("id") String id,
@RequestParam("name") String name,
Model alienModel) {
Alien newAlien = new Alien(id, name);
alienModel.addAttribute("alien", newAlien);
return "alienated";
}

Also create a jsp to show our data. Notice the use of alien model via the attribute name.

<html>
<head><title>alienated</title></head>
<body>
Hello alien
Entered id val : ${alien.id}<br>
Entered name val : ${alien.name}<br>

</body>
</html>

Result;

Now we can get the model directly from the view as well. Lets change the form action alienate to alienationWithStyle. Add below controller method;

@RequestMapping("alienationWithStyle")
public String alienationWithStyle(@ModelAttribute("alienStyle") Alien alien) {
return "alienated";
}

Here we receieved a model directly from the view and set the attr name to alienStyle which we will use to work with on the alienated.jsp page. ModelAtribute annotation sets the attribute name of the model and fills it with incoming data from the view.

Note: We can use Alien class directly as method param like below if we use the same name in the redirected view.

public String alienationWithStyle(Alien alien) {...Entered id val : ${alien.id}<br>
Entered name val : ${alien.name}<br>

In the case we need to have readily available but not action result data in a view as we enter, we can do so via the ModelAttribute annotation use;

${somethingElse}  
...
@ModelAttribute
public void readyModelData(Model genericModel){
genericModel.addAttribute("somethingElse","this is from somewhere else");
}

Theres one problem with the requests we make so far, our requests are sent using GET and they are passed on parameter.

http://localhost:8080/alienationWithStyle?id=313&name=12

To fix this we add method post to form in jsp to get the same output

<form action="alienate" method="post">....
http://localhost:8080/alienationWithStyle

OR, in RequestMapping like below so the controller method will only accept related method while sending

@RequestMapping(value = "alienate", method = RequestMethod.POST)
public String alienate(@RequestParam("id2") String id2,
....

All is great with Posts and such but how do we get data, for example a list of aliens from the path /aliens.

Spring ORM & JPA

So first lets start with adding dependencies for hibernate and finally PostgreSQL driver. So basically spring will handle the db entity- table relationship and the transactions.

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.2.20</version>
</dependency>

Next we add the necessary config for our db connections,

spring.datasource.url=jdbc:postgresql://192.168.56.109/springapp
spring.datasource.username=spring-usr
spring.datasource.password=123
spring.datasource.driverClassName=org.postgresql.Driver


spring.jpa.hibernate.hbm2ddl.auto=update
spring.jpa.hibernate.ejb.naming_strategy=org.hibernate.cfg.EJB3NamingStrategy
spring.jpa.hibernate.show_sql=true
spring.jpa.hibernate.format_sql=true
spring.jpa.hibernate.use_sql_comments=false
spring.jpa.hibernate.type=all
spring.jpa.hibernate.disableConnectionTracking=true
spring.jpa.hibernate.default_schema=spring-app

Add a table and schema like below. I created a user spring-usr as well.

Now add a repository to manage our connections to the db.

@Repository
public interface AlienRepository extends CrudRepository<Alien, Integer> {
Alien findById(int id);

@Transactional
List<Alien> findAll();

void deleteById(int id);
}

We then create an Entity to mirror what we want in the db. Entity annotation marks this class as a db template. We can further add table an schema details and mark Id fields with the Id annotation so it is used automatically for find methods.

@Entity
@Table(name = "alien", schema = "springapp")
public class Alien {
public Alien(int id, String name) {
this.id = id;
this.name = name;
}

public Alien() {
}

public int getId() {
return id;
}

public void setId(int id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

@Id
private int id;
private String name;
}

So only the logic of find, save and findAll methods are left. I tried to incorporate them in to the code as much as possible. The bold parts are new, for example in getAliens method, we retrieve all data from aliens table and send it as an object to showAliens page.

In findAliens method, we get the input of an ID and use that to add related object to the model which we transfer to alienated page.

In alienate method, we use anoher CrudRepository operation to save the data retireved from input to the db.

@Autowired
private AlienRepository alienRepo;

@GetMapping("getAliens")
public String getAliens(Model model){
model.addAttribute("alienList", alienRepo.findAll());
return "showAliens";
}

@GetMapping("findAliens")
public String findAliens(@RequestParam("id3") int id3, Model model){
model.addAttribute("alien", alienRepo.findById(id3));
return "alienated";
}
///@RequestMapping("alienate") will accept any method of connection
// below mapping will only accept POST
@RequestMapping(value = "alienate", method = RequestMethod.POST)
public String alienate(@RequestParam("id2") int id2,
@RequestParam("name2") String name2,
Model alienModel) {
Alien newAlien = new Alien(id2, name2);
alienRepo.save(newAlien);
alienModel.addAttribute(newAlien);
return "alienated";
}

The final status of the modified index.jsp is below

<html>
<head><title>Yay</title></head>
<body>
<br>standard prerendered text<br>
${somethingElse}
<br>
<br>
<br>read input via @ModelAttribute<br>
<form action="sendVals" method="post">
Enter text : <input type="text" name="text1"><br>
Enter num : <input type="number" name="num1"><br>
<input type="submit">
</form>

<br>read input via ModelAndView object use<br>
<form action="alienate" method="post">
Enter id : <input type="number" name="id2"><br>
Enter name : <input type="text" name="name2"><br>
<input type="submit">
</form>


<br>read input via @ModelAttribute<br>
<form action="alienationWithStyle" method="post">
Enter id : <input type="number" name="id"><br>
Enter name : <input type="text" name="name"><br>
<input type="submit">
</form>

<br>findaliens<br>
<form action="findAliens" method="get">
Enter id : <input type="number" name="id3"><br>
<input type="submit">
</form>
</body>
</html>

So trying out the code,

findById works
findAll seems to be ok
save is also working.

So this is it for Update 0. Some material in the early parts seem to be slightly outdated but in the next updates I’ll go over the document and the code once more.

UPDATE 1

Continuing where we left off, now that we can find by id and get all, how would we search by name for example.

So first add the following to index.jsp

<br>find alien by name<br>
<form action="findAlienByName" method="get">
Enter name : <input type="text" name="name3"><br>
<input type="submit">
</form>

Then in HomeController,

@GetMapping("findAlienByName")
public String findAlienByName(@RequestParam("name3") String name3, Model model){
model.addAttribute("alienList", alienRepo.findByName(name3));
return "showAliens";
}

Though we don’t have findByName anywhere, so we have to add it to our repo interface like this;

List<Alien> findByName(String name);

Checking returned values via debug when test2 is entered,

noice

Notice that even though no code is implemented, it works. We can also use findByNameOrderByNameDesc and similar. The rule is that the method must start with findBy, next letter must be capital, and the parameter must be one of the properties in the entity.

All is nice with the above stuff however, when I enter partial name like “tes” nothing is found also what if we want to find by some other parameter.

Well for partial name search, which would be like , we can use the same logic with findByName. In fact, we all should have a detailed reading of this.

findByNameIgnoreCaseContaining

For custom queries, lets add a part to index.jsp, controller and the repo. In the repo, Query annotation is used to create a custom query to run and param annotation is used to match the column name with the custom parameter.

<br>find alien with id bigger than the one entered<br>
<form action="findAlienWithBiggerId" method="get">
Enter id : <input type="number" name="id4"><br>
<input type="submit">
</form>
....
@GetMapping("findAlienWithBiggerId")
public String findAlienWithBiggerId(@RequestParam("id4") int id4, Model model){
model.addAttribute("alienList", alienRepo.filterAliensWithBiggerID(id4));
return "showAliens";
}
...
@Query ("from Alien where id > :id")
List<Alien> filterAliensWithBiggerID(@Param("id") int id);

SPRING REST

From here on out, we won’t need JSP anymore (yay), and we can proceed via postman.

We need to create another method called aliens in the controller but before this we should really seperate to another controller because its getting cluttered.

Lets create a controller and add a method mapped to aliens. Here the rest is usual but the bold part states that this is not something to be thrown to a view but rather must be returned as reponse. Remember in our app.properties we asked spring to look for whatever we return in /views/ folder and add .jsp to the end. If we are only going to use this controller as a rest service, we can add RestController annotation instead of Controller and remove ResponseBody annotations from methods.

@Controller
public class AlienController {
@Autowired
AlienRepository alienRepo;

@GetMapping("aliens")
@ResponseBody
public List<Alien> getAliens() {
return alienRepo.findAll();
}
}

Running …/aliens in postman gets…the result lol and in json format too. I guess they thought of everything.

So to find one alien, we create another method with a different path and a pathvariable annotated parameter to link the bold parts.

@GetMapping("alien/{id}")
@ResponseBody
public Alien getAlien(@PathVariable("id") int id) {
return alienRepo.findById(id);
}

Now lets add some aliens. We add another method bound to alien with a PostMapping. Apparently though if you add RequestBody annotation method expects JSON, if not it works as queryparam.

@PostMapping("alien")
@ResponseBody
public Alien addAlien(@RequestBody Alien alien) {
alienRepo.save(alien);
return alien;
}

SPRING AOP

So a logic where AOP could be understood easier is the logging mechanisms. Considering our RestController AlienController, logging method details are unrelated to the business logic within those methods so they should not exist there, but where do we place them.

So considering we have a simple class like below which we want to gather logs of our AlienController methods.

public class LoggingAspect {

public void log(){
System.out.println();
}
}

In AOP there are some terminologies which we must apply this logic to, namely;

  • Aspect: Our LoggingAspect class,
  • Join point: The methods of the controller where log will be executed
  • Advice: Our log method which we want to call from controller methods
  • Weaving: When we want to connect

So we first start by annotating our class as Aspect and adding a Component annotation as well so Spring knows about it.

We use Before annotation to state that log will work before the method start,

Now we want all our controller methods to log when they start and finish. So we create 2 methods with Before and After annotations and state that when spring start executing execution any of the methods in the controller give us the joinPoint as parameter which will contain info about the executing method. Same applies for after method as well.

@Before("execution(* com.springtests.mvc.springmvctest.controller.AlienController.*(..)) && args(..)\"")
public void logBefore(JoinPoint joinPoint){
LOGGER.info("Starting method from aspect, method name:"+joinPoint.getSignature().getName());
}

@After("execution(* com.springtests.mvc.springmvctest.controller.AlienController.*(..)) && args(..)\"")
public void logAfter(JoinPoint joinPoint){
LOGGER.info("Ending method from aspect, method name:"+joinPoint.getSignature().getName());
}

Now let’s run this.

insanity

Additionally we can do the same for succesful execution, exceptions and in finally moments of the methods as well. Putting this link here, as reference for later.

SPRING SECURITY

If we wanted to secure our application we can

to our dependencies we add spring boot security.

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.4.5</version>
</dependency>

Now when we restart,

well,

We can see the generated user password from our logs.

Our rest service is also getting 401 naturally

While this is great and all, we need to modify our configs a little. We can do so by creating a config class called AppSecurityConfig. We create 2 users to be in memory.

@Configuration
@EnableWebSecurity
public class AppSecurityConfig extends WebSecurityConfigurerAdapter {

@Bean
@Override
protected UserDetailsService userDetailsService() {
List<UserDetails> users=new ArrayList<>();
users.add(User.withDefaultPasswordEncoder().username("test1").password("123").roles("USER").build());
users.add(User.withDefaultPasswordEncoder().username("test2").password("123").roles("ADMIN").build());
return new InMemoryUserDetailsManager(users);
}
}

When we restart, we can use test1 and test2 users and a user won’t be autogenerated.

This is ok for testing but let’s to a db auth instead.

First we need the table we are going to connect to. Last 2 users are for later use.

CREATE TABLE springapp."user" (
id numeric NULL,
username varchar NULL,
"password" varchar NULL
);
INSERT INTO springapp."user"
(id, username, "password")
VALUES(1, 'test1', '123');
INSERT INTO springapp."user"
(id, username, "password")
VALUES(2, 'test2', '123');
INSERT INTO springapp."user"
(id, username, "password")
VALUES(3, 'test3', '$2a$10$GwCrHmwBv9Tae9mvDCTeXuLjl6UO.0gQo.svUZczolE3.IkSn2psi');
INSERT INTO springapp."user"
(id, username, "password")
VALUES(4, 'test4', '$2a$10$GwCrHmwBv9Tae9mvDCTeXuLjl6UO.0gQo.svUZczolE3.IkSn2psi');

Then a User Entity to carry the user info

@Entity
public class User {
@Id
private int id;
private String username;
private String password;

public int getId() {
return id;
}

public void setId(int id) {
this.id = id;
}

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}
}

We open AppSecurityConfig again, comment the previous code and add the below. So what we do here is define a db auth provider, state that the providers UserDetailsService is something we don’t have yet and set no password encoding which we will fix later. We also autowire the nonexistant userDetailsService.

@Autowired
private UserDetailsService userDetailsService;
//returns the authentication provider- ours will be db
@Bean
public AuthenticationProvider authProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService());
provider.setPasswordEncoder(NoOpPasswordEncoder.getInstance());

return provider;
}

We then create the repository we will use to interact with the table.

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}

So normally in an application, a controller interacts with service which then interacts with the db layer. Service annotation states that the class will contain business logic of a service. Now lets create this service logic to handle operations;

@Service
public class DbUserDetailsService implements UserDetailsService {

@Autowired
private UserRepository userRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

User user = userRepository.findByUsername(username);
if(user==null)
throw new UsernameNotFoundException("User not in db");
//USER DETAILS IS AN INTERFACE?
return null;
}
}

The problem above is, sure we got the user using the repo but we have no means of returning the data. We need a class implementing UserDetails interface. Lets create a UserPrincipal (current user) class. We can set what to do in each situation like forgotten pass, etc but for now lets set to true.

public class UserPrincipal implements UserDetails {

private User user;

public UserPrincipal(User user){
super();
this.user=user;
}


//collectipon of authorities
//authority level handling under normal circumstances
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(new SimpleGrantedAuthority("USER"));
}

@Override
public String getPassword() {
return user.getPassword();
}

@Override
public String getUsername() {
return user.getUsername();
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

Now lets fix DbUserDetailsService. Instead of returning null, we can return our UserPrincipal object init with the user we get from the db.

return new UserPrincipal(user);

We should be able to login with the users in the db. Now lets solve the pass encryption problem. In app security config, we comment out the code that sets the password encoder and put in BCrypt encoder. Spring has default support built in. With this in place, we can use the last 2 users we had test3 and test4.

provider.setPasswordEncoder(new BCryptPasswordEncoder());

UPDATE 3 will include,

  • Spring Cloud

Thanks for reading.

--

--

Yiğit İrez
Yiğit İrez

Written by Yiğit İrez

Let’s talk devops, automation and architectures, everyday, all day long. https://www.linkedin.com/in/yigitirez/

No responses yet