Skip to content

Commit e60cf60

Browse files
author
Eric Harmeling
committed
Spring Boot JDBC tutorial
1 parent 252a105 commit e60cf60

14 files changed

+1315
-0
lines changed

_includes/sidebar-data-v20.1.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,17 @@
700700
}
701701
]
702702
},
703+
{
704+
"title": "Roach Data",
705+
"items": [
706+
{
707+
"title": "Spring Boot with JDBC",
708+
"urls": [
709+
"/${VERSION}/build-a-spring-app-with-cockroachdb-jdbc.html"
710+
]
711+
}
712+
]
713+
},
703714
{
704715
"title": "Multi-Region",
705716
"items": [
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.roach.data.jdbc;
2+
3+
import java.math.BigDecimal;
4+
5+
import org.springframework.data.annotation.Id;
6+
7+
/**
8+
* Domain entity mapped to the account table.
9+
*/
10+
public class Account {
11+
@Id
12+
private Long id;
13+
14+
private String name;
15+
16+
private AccountType type;
17+
18+
private BigDecimal balance;
19+
20+
public Long getId() {
21+
return id;
22+
}
23+
24+
public String getName() {
25+
return name;
26+
}
27+
28+
public AccountType getType() {
29+
return type;
30+
}
31+
32+
public BigDecimal getBalance() {
33+
return balance;
34+
}
35+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package io.roach.data.jdbc;
2+
3+
import java.math.BigDecimal;
4+
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.dao.DataRetrievalFailureException;
7+
import org.springframework.data.domain.PageRequest;
8+
import org.springframework.data.domain.Pageable;
9+
import org.springframework.data.domain.Sort;
10+
import org.springframework.data.web.PageableDefault;
11+
import org.springframework.data.web.PagedResourcesAssembler;
12+
import org.springframework.hateoas.IanaLinkRelations;
13+
import org.springframework.hateoas.Link;
14+
import org.springframework.hateoas.PagedModel;
15+
import org.springframework.hateoas.RepresentationModel;
16+
import org.springframework.hateoas.server.RepresentationModelAssembler;
17+
import org.springframework.http.HttpEntity;
18+
import org.springframework.http.HttpStatus;
19+
import org.springframework.http.ResponseEntity;
20+
import org.springframework.transaction.annotation.Transactional;
21+
import org.springframework.web.bind.annotation.*;
22+
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
23+
24+
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
25+
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
26+
import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW;
27+
28+
/**
29+
* Main remoting and transaction boundary in the form of a REST controller. The discipline
30+
* when following the entity-control-boundary (ECB) pattern is that only service boundaries
31+
* are allowed to start and end transactions. A service boundary can be a controller, business
32+
* service facade or service activator (JMS/Kafka listener).
33+
* <p>
34+
* This is enforced by the REQUIRES_NEW propagation attribute of @Transactional annotated
35+
* controller methods. Between the web container's HTTP listener and the transaction proxy,
36+
* there's yet another transparent proxy in the form of a retry loop advice with exponential
37+
* backoff. It takes care of retrying transactions that are aborted by transient SQL errors,
38+
* rather than having these propagate all the way over the wire to the client / user agent.
39+
*
40+
* @see RetryableTransactionAspect
41+
*/
42+
@RestController
43+
public class AccountController {
44+
@Autowired
45+
private AccountRepository accountRepository;
46+
47+
@Autowired
48+
private PagedResourcesAssembler<Account> pagedResourcesAssembler;
49+
50+
/**
51+
* Provides the service index resource representation which is only links
52+
* for clients to follow.
53+
*/
54+
@GetMapping
55+
public ResponseEntity<RepresentationModel> index() {
56+
RepresentationModel index = new RepresentationModel();
57+
58+
// Type-safe way to generate URLs bound to controller methods
59+
index.add(linkTo(methodOn(AccountController.class)
60+
.listAccounts(PageRequest.of(0, 5)))
61+
.withRel("accounts")); // Lets skip curies and affordances for now
62+
63+
// This rel essentially informs the client that a POST to its href with
64+
// form parameters will transfer funds between referenced accounts.
65+
// (its only a demo)
66+
index.add(linkTo(AccountController.class)
67+
.slash("transfer{?fromId,toId,amount}")
68+
.withRel("transfer"));
69+
70+
// Spring boot actuators for observability / monitoring
71+
index.add(new Link(
72+
ServletUriComponentsBuilder
73+
.fromCurrentContextPath()
74+
.pathSegment("actuator")
75+
.buildAndExpand()
76+
.toUriString()
77+
).withRel("actuator"));
78+
79+
return new ResponseEntity<>(index, HttpStatus.OK);
80+
}
81+
82+
/**
83+
* Provides a paged representation of accounts (sort order omitted).
84+
*/
85+
@GetMapping("/account")
86+
@Transactional(propagation = REQUIRES_NEW)
87+
public HttpEntity<PagedModel<AccountModel>> listAccounts(
88+
@PageableDefault(size = 5, direction = Sort.Direction.ASC) Pageable page) {
89+
return ResponseEntity
90+
.ok(pagedResourcesAssembler.toModel(accountRepository.findAll(page), accountModelAssembler()));
91+
}
92+
93+
/**
94+
* Provides a point lookup of a given account.
95+
*/
96+
@GetMapping(value = "/account/{id}")
97+
@Transactional(propagation = REQUIRES_NEW, readOnly = true) // Notice its marked read-only
98+
public HttpEntity<AccountModel> getAccount(@PathVariable("id") Long accountId) {
99+
return new ResponseEntity<>(accountModelAssembler().toModel(
100+
accountRepository.findById(accountId)
101+
.orElseThrow(() -> new DataRetrievalFailureException("No such account: " + accountId))),
102+
HttpStatus.OK);
103+
}
104+
105+
/**
106+
* Main funds transfer method.
107+
*/
108+
@PostMapping(value = "/transfer")
109+
@Transactional(propagation = REQUIRES_NEW)
110+
public HttpEntity<BigDecimal> transfer(
111+
@RequestParam("fromId") Long fromId,
112+
@RequestParam("toId") Long toId,
113+
@RequestParam("amount") BigDecimal amount
114+
) {
115+
if (amount.compareTo(BigDecimal.ZERO) < 0) {
116+
throw new IllegalArgumentException("Negative amount");
117+
}
118+
if (fromId.equals(toId)) {
119+
throw new IllegalArgumentException("From and to accounts must be different");
120+
}
121+
122+
BigDecimal fromBalance = accountRepository.getBalance(fromId).add(amount.negate());
123+
// Application level invariant check.
124+
// Could be enhanced or replaced with a CHECK constraint like:
125+
// ALTER TABLE account ADD CONSTRAINT check_account_positive_balance CHECK (balance >= 0)
126+
if (fromBalance.compareTo(BigDecimal.ZERO) < 0) {
127+
throw new NegativeBalanceException("Insufficient funds " + amount + " for account " + fromId);
128+
}
129+
130+
accountRepository.updateBalance(fromId, amount.negate());
131+
accountRepository.updateBalance(toId, amount);
132+
133+
return ResponseEntity.ok().build();
134+
}
135+
136+
private RepresentationModelAssembler<Account, AccountModel> accountModelAssembler() {
137+
return (entity) -> {
138+
AccountModel model = new AccountModel();
139+
model.setName(entity.getName());
140+
model.setType(entity.getType());
141+
model.setBalance(entity.getBalance());
142+
model.add(linkTo(methodOn(AccountController.class)
143+
.getAccount(entity.getId())
144+
).withRel(IanaLinkRelations.SELF));
145+
return model;
146+
};
147+
}
148+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.roach.data.jdbc;
2+
3+
import java.math.BigDecimal;
4+
5+
import org.springframework.hateoas.RepresentationModel;
6+
import org.springframework.hateoas.server.core.Relation;
7+
8+
/**
9+
* Account resource represented in HAL+JSON via REST API.
10+
*/
11+
@Relation(value = "account", collectionRelation = "accounts")
12+
public class AccountModel extends RepresentationModel<AccountModel> {
13+
private String name;
14+
15+
private AccountType type;
16+
17+
private BigDecimal balance;
18+
19+
public String getName() {
20+
return name;
21+
}
22+
23+
public void setName(String name) {
24+
this.name = name;
25+
}
26+
27+
public AccountType getType() {
28+
return type;
29+
}
30+
31+
public void setType(AccountType type) {
32+
this.type = type;
33+
}
34+
35+
public BigDecimal getBalance() {
36+
return balance;
37+
}
38+
39+
public void setBalance(BigDecimal balance) {
40+
this.balance = balance;
41+
}
42+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.roach.data.jdbc;
2+
3+
import java.math.BigDecimal;
4+
5+
6+
import org.springframework.data.domain.Page;
7+
import org.springframework.data.jdbc.repository.query.Modifying;
8+
import org.springframework.data.jdbc.repository.query.Query;
9+
import org.springframework.data.repository.PagingAndSortingRepository;
10+
import org.springframework.data.repository.query.Param;
11+
import org.springframework.stereotype.Repository;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import static org.springframework.transaction.annotation.Propagation.MANDATORY;
15+
16+
/**
17+
* The main account repository, notice there's no implementation needed since its auto-proxied by
18+
* spring-data.
19+
* <p>
20+
* Should have extended PagingAndSortingRepository in normal cases.
21+
*/
22+
@Repository
23+
@Transactional(propagation = MANDATORY)
24+
public interface AccountRepository extends PagingAndSortingRepository<Account, Long> {
25+
26+
@Query("SELECT * FROM account LIMIT :pageSize OFFSET :offset")
27+
Page<Account> findAll(@Param("pageSize") int pageSize, @Param("offset") long offset);
28+
29+
@Query("SELECT count(id) FROM account")
30+
long countAll();
31+
32+
@Query(value = "SELECT balance FROM account WHERE id=:id")
33+
BigDecimal getBalance(@Param("id") Long id);
34+
35+
@Modifying
36+
@Query("UPDATE account SET balance = balance + :balance WHERE id=:id")
37+
void updateBalance(@Param("id") Long id, @Param("balance") BigDecimal balance);
38+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.roach.data.jdbc;
2+
3+
public enum AccountType {
4+
asset,
5+
expense
6+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package io.roach.data.jdbc;
2+
3+
import java.math.BigDecimal;
4+
import java.util.ArrayDeque;
5+
import java.util.Deque;
6+
import java.util.HashMap;
7+
import java.util.Map;
8+
import java.util.concurrent.ExecutionException;
9+
import java.util.concurrent.Executors;
10+
import java.util.concurrent.Future;
11+
import java.util.concurrent.ScheduledExecutorService;
12+
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
import org.springframework.boot.CommandLineRunner;
16+
import org.springframework.boot.WebApplicationType;
17+
import org.springframework.boot.autoconfigure.SpringBootApplication;
18+
import org.springframework.boot.builder.SpringApplicationBuilder;
19+
import org.springframework.context.annotation.EnableAspectJAutoProxy;
20+
import org.springframework.core.Ordered;
21+
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
22+
import org.springframework.hateoas.Link;
23+
import org.springframework.hateoas.config.EnableHypermediaSupport;
24+
import org.springframework.http.HttpEntity;
25+
import org.springframework.http.HttpMethod;
26+
import org.springframework.transaction.annotation.EnableTransactionManagement;
27+
import org.springframework.web.client.HttpClientErrorException;
28+
import org.springframework.web.client.RestTemplate;
29+
30+
/**
31+
* Spring boot server application using spring-data-jdbc for data access.
32+
*/
33+
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
34+
@EnableJdbcRepositories
35+
@EnableAspectJAutoProxy(proxyTargetClass = true)
36+
@EnableSpringDataWebSupport
37+
@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE - 1) // Bump up one level to enable extra advisors
38+
@SpringBootApplication
39+
public class JdbcApplication implements CommandLineRunner {
40+
protected static final Logger logger = LoggerFactory.getLogger(JdbcApplication.class);
41+
42+
public static void main(String[] args) {
43+
new SpringApplicationBuilder(JdbcApplication.class)
44+
.web(WebApplicationType.SERVLET)
45+
.run(args);
46+
}
47+
48+
@Override
49+
public void run(String... args) {
50+
for (String a : args) {
51+
if ("--skip-client".equals(a)) {
52+
return;
53+
}
54+
}
55+
56+
logger.info("Lets move some $$ around!");
57+
58+
final Link transferLink = new Link("http://localhost:8080/transfer{?fromId,toId,amount}");
59+
60+
final int threads = Runtime.getRuntime().availableProcessors();
61+
62+
final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(threads);
63+
64+
Deque<Future<?>> futures = new ArrayDeque<>();
65+
66+
for (int i = 0; i < threads; i++) {
67+
Future<?> future = executorService.submit(() -> {
68+
for (int j = 0; j < 100; j++) {
69+
int fromId = 1 + (int) Math.round(Math.random() * 3);
70+
int toId = fromId % 4 + 1;
71+
72+
BigDecimal amount = new BigDecimal("10.00");
73+
74+
Map<String, Object> form = new HashMap<>();
75+
form.put("fromId", fromId);
76+
form.put("toId", toId);
77+
form.put("amount", amount);
78+
79+
String uri = transferLink.expand(form).getHref();
80+
81+
try {
82+
new RestTemplate().exchange(uri, HttpMethod.POST, new HttpEntity<>(null), String.class);
83+
} catch (HttpClientErrorException.BadRequest e) {
84+
logger.warn(e.getResponseBodyAsString());
85+
}
86+
}
87+
});
88+
futures.add(future);
89+
}
90+
91+
while (!futures.isEmpty()) {
92+
try {
93+
futures.pop().get();
94+
logger.info("Worker finished - {} remaining", futures.size());
95+
} catch (InterruptedException e) {
96+
Thread.currentThread().interrupt();
97+
} catch (ExecutionException e) {
98+
logger.warn("Worker failed", e.getCause());
99+
}
100+
}
101+
102+
logger.info("All client workers finished but server keeps running. Have a nice day!");
103+
104+
executorService.shutdownNow();
105+
}
106+
}
107+

0 commit comments

Comments
 (0)