- Install the Cloud Foundry CLI from this URL: https://docs.cloudfoundry.org/cf-cli/install-go-cli.html
- Verify the install by opening a terminal or command window and typing
cf --version. You should see a version string to match the version you installed
Install and configure a Java IDE you are comfortable with. Good options include:
- Eclipse: https://www.eclipse.org/
- IntelliJ: https://www.jetbrains.com/idea/
- Visual Studio Code: https://visualstudio.microsoft.com/
If you install Visual Studio Code, then add the following extensions:
- (Microsoft) Java Extension Pack
- (Pivotal) Spring Boot Extension Pack
If you are using a private installation of PCF, then obtain credentials and API enpoint information from your PCF platform team. If you are using Pivotal Web Services (the public PCF instance hosted by Pivotal), then go to https://run.pivotal.io/ and register for a free account.
Once you have credentials, login with the CLI...
- Open a terminal or command window and login to PCF with the command
cf login -a api.run.pivotal.io(or whatever API endpoint you are using if not Pivotal Web Services) - Enter the email you registered and the password you set
- Navigate to https://start.spring.io
- Create a Maven project with Java and the latest version of Spring Boot (2.3.3 at the time of writing)
- Specify group:
microservice.workshop - Specify artifact:
redis-demo - Specify packaging: Jar
- Specify Java Version to match what you have installed
- For dependencies, add the following:
- Spring Web Starter
- Spring Boot Actuator
- Spring Data Redis
- Generate the project (causes a download)
- Unzip the downloaded file somewhere convenient
- Add the new project to your IDE workspace
- Eclipse: File->Import->Existing Maven Project
- IntelliJ: File->New->Module From Existing Sources...
- VS Code: File->Add Folder to Workspace (or just open the folder by navigating to it and entering the command
code .)
-
Rename
application.propertiesinsrc/main/resourcestoapplication.yml -
Open
application.ymlinsrc/main/resources -
Add this value
info: app: name: Payment Service management: endpoint: health: show-details: always
-
Create a file called
application-default.ymlinsrc/main/resources -
Set its content to the following:
spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
This will tell SpringBoot not to configure Redis when we're running locally - even though Redis is on the classpath. Failure to do this will not stop the application from starting and running successfully. But the health actuator will show the application being down.
-
Create a file called
application-cloud.ymlinsrc/main/resources -
Set its content to the following:
spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
This will tell SpringBoot not to configure Redis when we're running in the cloud - even though Redis is on the classpath. We will remove this file once we're ready to work with Redis.
-
Open
pom.xml, add the following dependencies:<dependency> <groupId>io.springfox</groupId> <artifactId>springfox-boot-starter</artifactId> <version>3.0.0</version> </dependency>
-
Create a class
SwaggerConfigurationin themicoservice.workshop.redisdemopackage. Add the following:package microservice.workshop.redisdemo; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.view.RedirectView; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2; @Configuration @EnableSwagger2 @Controller public class SwaggerConfiguration { @RequestMapping("/") public RedirectView redirectToSwagger() { return new RedirectView("swagger-ui/"); } @Bean public Docket api() { return new Docket(DocumentationType.SWAGGER_2) .select() .apis(RequestHandlerSelectors.withClassAnnotation(RestController.class)) .build(); } }
This configuration does three important things:
- It enables Swagger
- It redirects the root URL to the Swagger UI. I find this convenient, but YMMV
- It tells Springfox that we only want to use Swagger for REST controllers. Without this there will be Swagger documentation for the redirect controller, as well as the basic Spring error controller and we usually don't want this.
-
Create a package
microservice.workshop.redisdemo.service -
Create a class in the new package called
PaymentService -
Set the content of
PaymentServiceto the following:package microservice.workshop.redisdemo.service; import java.math.BigDecimal; import java.math.RoundingMode; import org.springframework.stereotype.Service; @Service public class PaymentService { public BigDecimal calculate(double amount, double rate, int years) { if (rate == 0.0) { return calculateWithoutInterest(amount, years); } else { return calculateWithInterest(amount, rate, years); } } private BigDecimal calculateWithInterest(double amount, double rate, int years) { double monthlyRate = rate / 100.0 / 12.0; int numberOfPayments = years * 12; double payment = (monthlyRate * amount) / (1.0 - Math.pow(1.0 + monthlyRate, -numberOfPayments)); return toMoney(payment); } private BigDecimal calculateWithoutInterest(double amount, int years) { int numberOfPayments = years * 12; return toMoney(amount / numberOfPayments); } private BigDecimal toMoney(double d) { BigDecimal bd = new BigDecimal(d); return bd.setScale(2, RoundingMode.HALF_UP); } }
-
Create an interface in the
microservice.workshop.redisdemo.servicepackage calledHitCounterService -
Set the content of
HitCounterServiceto the following:package microservice.workshop.redisdemo.service; public interface HitCounterService { long incrementCounter(); void resetCount(); }
-
Create a class in the
microservice.workshop.redisdemo.servicepackage calledMemoryHitCounterService -
Set the content of
MemoryHitCounterServiceto the following:package microservice.workshop.redisdemo.service; import org.springframework.stereotype.Service; @Service public class MemoryHitCounterService implements HitCounterService { private long hitCount = 0; @Override public long incrementCounter() { return ++hitCount; } @Override public void resetCount() { hitCount = 0; } }
-
Create a class in the
microservice.workshop.redisdemo.servicepackage calledCrashService -
Set the content of
CrashServiceto the following:package microservice.workshop.redisdemo.service; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.springframework.stereotype.Service; @Service public class CrashService { private ScheduledExecutorService executer = Executors.newScheduledThreadPool(1); // calls System.exit after a 2 second delay public void crashIt() { executer.schedule(() -> System.exit(22), 2000, TimeUnit.MILLISECONDS); } }
-
Create a package
microservice.workshop.redisdemo.model -
Create a class in the new package called
CalculatedPayment -
Set the content of
CalculatedPaymentto the following:package microservice.workshop.redisdemo.model; import java.math.BigDecimal; public class CalculatedPayment { private double amount; private double rate; private int years; private BigDecimal payment; private String instance; private Long count; // TODO: add getters and setters for all fields... }
-
Create a package
microservice.workshop.redisdemo.http -
Create a class in the new package called
PaymentController -
Set the content of
PaymentControllerto the following:package microservice.workshop.redisdemo.http; import java.math.BigDecimal; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import microservice.workshop.redisdemo.model.CalculatedPayment; import microservice.workshop.redisdemo.service.HitCounterService; import microservice.workshop.redisdemo.service.PaymentService; @CrossOrigin(origins = "*") @RestController @RequestMapping("/payment") public class PaymentController { @Value("${cloud.application.instance_index:local}") private String instance; @Autowired private HitCounterService hitCounterService; @Autowired private PaymentService paymentService; private static final Logger logger = LoggerFactory.getLogger(PaymentController.class); @GetMapping() public CalculatedPayment calculatePayment(@RequestParam("amount") double amount, @RequestParam("rate") double rate, @RequestParam("years") int years) { BigDecimal payment = paymentService.calculate(amount, rate, years); logger.debug("Calculated payment of {} for input amount: {}, rate: {}, years: {}", payment, amount, rate, years); CalculatedPayment calculatedPayment = new CalculatedPayment(); calculatedPayment.setAmount(amount); calculatedPayment.setRate(rate); calculatedPayment.setYears(years); calculatedPayment.setPayment(payment); calculatedPayment.setInstance(instance); calculatedPayment.setCount(hitCounterService.incrementCounter()); return calculatedPayment; } }
This is needed for the unit tests - it will reset the hit counter to a known state for each test.
-
Create a class
ResetHitCounterControllerin packagemicroservice.workshop.redisdemo.http -
Set the content of
ResetHitCounterControllerto the following:package microservice.workshop.redisdemo.http; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import microservice.workshop.redisdemo.service.HitCounterService; @CrossOrigin(origins = "*") @RestController @RequestMapping("/resetCount") public class ResetHitCounterController { @Autowired private HitCounterService hitCounterService; @GetMapping public void reset() { hitCounterService.resetCount(); } }
This is needed to demonstrate Cloud Foundriy's self-healing capabilities.
-
Create a class
CrashControllerin packagemicroservice.workshop.redisdemo.http -
Set the content of
CrashControllerto the following:package microservice.workshop.redisdemo.http; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.swagger.annotations.ApiOperation; import microservice.workshop.redisdemo.service.CrashService; @CrossOrigin(origins = "*") @RestController @RequestMapping("/crash") public class CrashController { @Autowired private CrashService crashService; @ApiOperation("Warning! The application will crash 2 seconds after this method is called") @GetMapping() public String crashIt() { crashService.crashIt(); return "OK"; } }
-
Make a new package
microservice.workshop.redisdemo.httpin thesrc/test/javatree -
Create a class in the new package called
PaymentControllerTest -
Set the content of
PaymentControllerTestto the following:package microservice.workshop.redisdemo.http; import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; @ExtendWith(SpringExtension.class) @SpringBootTest public class PaymentControllerTest { private MockMvc mockMvc; @Autowired private WebApplicationContext webApplicationContext; @BeforeEach public void setup() { this.mockMvc = webAppContextSetup(webApplicationContext).build(); } @Test public void testWithInterest() throws Exception { mockMvc.perform(get("/resetCount")) .andExpect(status().is(HttpStatus.OK.value())); mockMvc.perform(get("/payment?amount=100000&rate=3.5&years=30")) .andExpect(status().is(HttpStatus.OK.value())) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.payment", is(449.04))) .andExpect(jsonPath("$.count", is(1))); } @Test public void testZeroInterest() throws Exception { mockMvc.perform(get("/resetCount")) .andExpect(status().is(HttpStatus.OK.value())); mockMvc.perform(get("/payment?amount=100000&rate=0&years=30")) .andExpect(status().is(HttpStatus.OK.value())) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.payment", is(277.78))) .andExpect(jsonPath("$.count", is(1))); } @Test public void testThatHitCounterIncrements() throws Exception { mockMvc.perform(get("/resetCount")) .andExpect(status().is(HttpStatus.OK.value())); mockMvc.perform(get("/payment?amount=100000&rate=3.5&years=30")) .andExpect(status().is(HttpStatus.OK.value())) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.payment", is(449.04))) .andExpect(jsonPath("$.count", is(1))); mockMvc.perform(get("/payment?amount=100000&rate=0&years=30")) .andExpect(status().is(HttpStatus.OK.value())) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.payment", is(277.78))) .andExpect(jsonPath("$.count", is(2))); } }
-
Run the unit tests:
- (Windows Command Prompt)
mvnw clean test - (Windows Powershell)
.\mvnw clean test - (Mac/Linux)
./mvnw clean test - Or your IDE's method of running tests
- (Windows Command Prompt)
-
Start the application:
- (Windows Command Prompt)
mvnw spring-boot:run - (Windows Powershell)
.\mvnw spring-boot:run - (Mac/Linux)
./mvnw spring-boot:run - Or your IDE's method of running the main application class
- (Windows Command Prompt)
-
Test Swagger http://localhost:8080
-
Test the acuator health endpoint http://localhost:8080/actuator/health
-
Test the acuator info endpoint http://localhost:8080/actuator/info
-
Create a file
manifest.ymlin the project root directory. Set it's contents to the following:applications: - name: RedisDemo-1.0 path: target/redis-demo-0.0.1-SNAPSHOT.jar random-route: true env: JBP_CONFIG_OPEN_JDK_JRE: "{jre: {version: 11.+}}"
Note that this manifest specifies JRE 11+. This is not required if you are using Java 8 (the Java buildpack default). If you are using a different version of Java, you can change the environment variable accordingly.
-
Build the application JAR file:
- (Windows Command Prompt)
mvnw clean package - (Windows Powershell)
.\mvnw clean package - (Mac/Linux)
./mvnw clean package - Or your IDE's method of running the Maven build
- (Windows Command Prompt)
-
cf push
You should now be able to test the app with Swagger at the route created by PCF. One thing to note is that the hit counter will reset everytime you deploy (because it is stored in the app state). Also, when the app is scaled the hit counter will not be shared across instances.
- Login to Pivotal Apps Manager at https://run.pivotal.io/
- Inspect the application...specifically:
- On the app overview page, you should see the Spring Boot logo
- On the app overview page you should be able to inspect details of the app health
- On the logs page you should see the recent logs for the application, and be able to change logging levels
- On the threads page you should be able to obtain a thread dump
- On the settings page, there should be a Spring Info section. In that section you should be able to see the text you added to the info actuator by pressing the "View Raw JSON" button
- On the settings page you should be able to see the environment variable we specified in the manifest file, as well as environment variable supplied by Cloud Foundry
Some interesting CLI commands...
cf targetshows information about your current connection, also used to change org/space if you have more than onecf appswill show all apps deployed in your current org/spacecf routeswill show all routes in your current org/spacecf serviceswill show all services allocated to your current org/spacecf app RedisDemo-1.0will show detailed information about the applicationcf stop RedisDemo-1.0will start the appcf start RedisDemo-1.0will start the appcf events RedisDemo-1.0will sho recent events in the applications (starts, stops, etc.)cf logs RedisDemo-1.0 --recentwill dump recent logging information to the screencf logs RedisDemo-1.0will tail the application logcf ssh-enabled RedisDemo-1.0will report whether it is possible to SSH into the application containercf ssh RedisDemo-1.0will SSH into the application container
Applications can be scaled in two ways - through the app manager UI, or through the CLI. We will use the CLI.
- Start the loan-calculator-client web page by using this URL: https://jeffgbutler.github.io/payment-calculator-client/
- Enter the URL to your application (like https://redisdemo-10-shy-zebra.cfapps.io) in the Base URL textbox
- Press the "Start" button. You should see random traffic being generated to the application. You should also see the all traffic is routed to instance 0
- Scale the app by entering the command
cf scale RedisDemo-1.0 -i 2- this will request two instances of the app running. Eventually you should see traffic being routed to the two app instances. Notice that the hit count is not consistent. Why? - If you press the "Crash It!" button on the client page, then one of the app instances will crash. Which one depends on how the request was routed. Cloud Foundry will notice that an instance has crashed and will start a new instance automatically - this may take a few minutes to show
- You can scale the app back down by entering
cf scale RedisDemo-1.0 -i 1
You should have noticed that the hit counter is not consistent among the instances, and that it is reset when an app instance crashes. This will demonstrate the idea of epehemeral containers and that Cloud Foundry is designed for stateless applications. We will store the hit count in an external Redis cache in the next section to correct this issue.
- Login to Pivotal Apps Manager at https://run.pivotal.io/
- Navigate to your org/space
- Select the "services" tab
- Press the "Add a Service" button
- Create a new service...
- Select "Redis Cache"
- Select plan type "30 MB" - the free plan
- Set the instance name to "xxxredis" where "xxx" are your initials
-
Create a new class
RedisHitCounterServicein themicroservice.workshop.redisdemo.servicepackage -
Set the contents of
RedisHitCounterServiceto the following:package microservice.workshop.redisdemo.service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @Service @Profile("cloud") public class RedisHitCounterService implements HitCounterService { private static final String REDIS_KEY = "payment-calculator"; private static final int DEFAULT_VALUE = 5000; @Autowired private RedisTemplate<String, Integer> redisTemplate; @Override public long incrementCounter() { redisTemplate.opsForValue().setIfAbsent(REDIS_KEY, DEFAULT_VALUE); return redisTemplate.opsForValue().increment(REDIS_KEY); } @Override public void resetCount() { redisTemplate.opsForValue().set(REDIS_KEY, DEFAULT_VALUE); } }
-
Create a class
CloudConfigurationin themicroservice.workshop.redisdemopackage. Add the following:package microservice.workshop.redisdemo; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericToStringSerializer; @Configuration @Profile("cloud") public class CloudConfiguration { @Bean public RedisTemplate<String, Integer> redisTemplate(RedisConnectionFactory redisFactory) { RedisTemplate<String, Integer> template = new RedisTemplate<>(); template.setConnectionFactory(redisFactory); template.setValueSerializer(new GenericToStringSerializer<>(Integer.class)); return template; } }
This configuration is enabled when the "cloud" profile is enabled only. On Cloud Foundry, the Java build pack enables this profile. When enabled, this configuration will create a Redis connection based on the Redis instance bound to the application.
-
Open
MemoryHitCounterService.javain themicroservice.workshop.redisdemo.servicepackage -
Change the service so that it is only active when not on the cloud:
@Service @Profile("!cloud") public class MemoryHitCounterService implements HitCounterService { ... }
-
Modify the file
manifest.ymlin the project root directory. Set it's contents to the following:applications: - name: RedisDemo-1.1 path: target/redis-demo-0.0.1-SNAPSHOT.jar random-route: true env: JBP_CONFIG_OPEN_JDK_JRE: "{jre: {version: 11.+}}" services: - xxxredis
Where "xxxredis" maps to the name of the redis instance you created above.
Important Note: you should also change the name of the application to denote the new version.
-
Delete the file
application-cloud.ymlinsrc/main/resources -
Build the application:
- (Windows Command Prompt)
mvnw clean package - (Windows Powershell)
.\mvnw clean package - (Mac/Linux)
./mvnw clean package - Or your IDE's method of running the Maven build
- (Windows Command Prompt)
-
cf push
You should now be able to test the app with Swagger at the route created by PCF. The hit counter will now persist across deploymant and will be consistent as the app scales.
- Run
cf targetto determine your space name. cf create-route <<your_space_name>> cfapps.io --hostname xxx-payment-calculatorcf map-route RedisDemo-1.0 cfapps.io --hostname xxx-payment-calculator
Change the URL in the client page to the new route you've created (https://jgb-payment-calculator.cfapps.io). You should see traffic being routed to your app.
Now route traffic to the 1.1 version of the app:
cf map-route RedisDemo-1.1 cfapps.io --hostname xxx-payment-calculator
You should now see traffic being routed to the 1.0 and the 1.1 version of the application.
Now take away the route to the 1.0 version of the app:
cf unmap-route RedisDemo-1.0 cfapps.io --hostname xxx-payment-calculator