Rajapinnat ja REST
Olemme tähän asti tarkastelleet sovelluksia, jotka tuottavat käyttäjälle jonkinlaisen näkymän ja mahdollistavat sitä kautta sovelluksen käytön. Tutustutaan seuraavaksi sovelluksiin, jotka näkymän sijaan tarjoavat rajapinnan tiedon hakemiseen. Tällaisten rajapinnan tarjoavien sovellusten käyttäjiä ovat ensisijaisesti muut sovellukset sekä niiden kehittäjät.
Aloitamme eräästä ehkäpä tämän hetken suosituimmasta tavasta rajapintojen toteuttamiseen.
Representational state transfer
Representational state transfer (REST) on ohjelmointirajapintojen toteuttamiseen tarkoitettu arkkitehtuurimalli (tai tyyli). REST-malli määrittelee sovellukset tietoa käsittelevien osien (komponentit), tietokohteiden (resurssit), sekä näiden yhteyksien kautta.
Tietoa käsittelevät osat ovat selainohjelmisto, palvelinohjelmisto, ym. Resurssit ovat sovelluksen käsitteitä ja tietoa (henkilöt, kirjat, laskentaprosessit, laskentatulokset — käytännössä mikä tahansa voi olla resurssi) sekä niitä yksilöiviä osoitteita. Resurssikokoelmat ovat löydettävissä ja navigoitavissa: resurssikokoelma voi löytyä esimerkiksi osoitteesta /persons
, /books
, /processes
tai /results
. Yksittäisille resursseille määritellään uniikit osoitteet (esimerkiksi /persons/1
) ja resursseihin liittyvällä tiedolla on selkeästi määritelty ja deterministinen esitysmuoto — esim HTML, JSON tai XML.
Resursseja ja tietoa käsittelevien osien yhteys perustuu tyypillisesti asiakas-palvelin -malliin, missä asiakas tekee pyynnön ja palvelin kuuntelee ja käsittelee vastaanottamiaan pyyntöjä sekä palauttaa niihin vastauksia.
REST-rajapinnat web-sovelluksissa
HTTP-protokollan yli käsiteltävillä REST-rajapinnoilla on tyypillisesti seuraavat ominaisuudet:
- Juuriosoite resurssien käsittelyyn (esimerkiksi
/books
) - Resurssien esitysmuodon määrittelevä mediatyyppi (esimerkiksi
HTML
,JSON
, ...), joka kertoo asiakkaalle miten resurssiin liittyvä data tulee käsitellä. - Resursseja voidaan käsitellä HTTP-protokollan metodeilla (GET, POST, DELETE, ..)
Tarkastellaan tätä kirjojen käsittelyyn liittyvän esimerkin kautta. Kirjojen käsittelyyn ja muokkaamiseen tarkoitettu rajapinta voisi olla esimerkiksi seuraavanlainen:
GET
osoitteeseen/books
palauttaa kaikkien kirjojen tiedot.GET
osoitteeseen/books/{id}
, missä{id}
on yksittäisen kirjan yksilöivä tunniste, palauttaa kyseisen kirjan tiedot.PUT
osoitteeseen/books/{id}
, missä{id}
on yksittäisen kirjan yksilöivä tunniste, muokkaa kyseisen kirjan tietoja. Kirjan uudet tiedot lähetetään osana pyyntöä.DELETE
osoitteeseen/books/{id}
poistaa kirjan tietyllä tunnuksella.POST
osoitteeseen/books
luo uuden kirjan pyynnössä lähetettävän datan pohjalta.
Osoitteissa käytetään tyypillisesti substantiivejä — ei books?id={id}
vaan /books/{id}
. HTTP-pyynnön tyyppi määrittelee operaation — DELETE
-tyyppisellä pyynnöllä poistetaan, POST
-tyyppisellä pyynnöllä lisätään, PUT
-tyyppisellä pyynnöllä päivitetään, ja GET
-tyyppisellä pyynnöllä haetaan tietoja.
Datan muoto on toteuttajan päätettävissä. Tällä hetkellä suosituin datamuoto on JSON, sillä sen käyttäminen selainohjelmistoissa käytettävällä JavaScriptilla on suoraviivaista. Myös palvelinohjelmistot tukevat olioiden muuttamista JSON-muotoon.
Oletetaan että edellä kuvattu kirjojen käsittelyyn tarkoitettu rajapinta käsittelee JSON-muotoista dataa. Kirjaa kuvaava luokka on seuraavanlainen:
// pakkaus ja importit
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Book extends AbstractPersistable<Long> {
private String name;
}
Kun luokasta on tehty olio, jonka id
-muuttujan arvo on 2
ja nimi "Harry Potter and the Chamber of Secrets"
, on sen JSON-esitys (esimerkiksi) seuraavanlainen:
{
"id":2,
"name":"Harry Potter and the Chamber of Secrets"
}
JSON-notaatio määrittelee olion alkavalla aaltosululla {
, jota seuraa oliomuuttujien nimet ja niiden arvot. Lopulta olio päätetään sulkevaan aaltosulkuun }
. Oliomuuttujien nimet ovat hipsuissa "
sillä ne käsitellään merkkijonoina. Muuttujien arvot ovat arvon tyypistä riippuen hipsuissa. Tarkempi kuvaus JSON-notaatiosta löytyy sivulta json.org.
Pyynnön rungossa lähetettävän JSON-muotoisen datan muuntaminen olioksi onnistuu Springissä annotaation @RequestBody avulla. Annotaation @RequestBody
tulee edeltää kontrollerimetodin parametrina olevaa oliota, johon palvelimelle lähetetyn JSON-muotoisen datan arvot halutaan asettaa.
@PostMapping("/books")
public String postBook(@RequestBody Book book) {
bookRepository.save(book);
return "redirect:/books";
}
Jotta yllä oleva esimerkki toimisi, tulee palvelimelle lähetettävän JSON-muotoisen datan muuttujien nimien vastata parametrina olevan olion muuttujien nimiä.
Palvelin palauttaa käyttäjälle JSON
-muotoisen vastauksen mikäli pyyntöä käsittelevässä metodissa on annotaatio @ResponseBody ja palauttamalla metodista olio. Annotaatio @ResponseBody
pyytää Spring-sovelluskehystä asettamaan palvelimen tuottama datan selaimelle lähetettävän vastauksen runkoon. Jos vastaus on olio, muutetaan se (oletuksena) automaattisesti JSON-muotoiseksi vastaukseksi.
Alla olevassa esimerkissä määritellään kontrolleriluokan metodi, joka kuuntelee pyyntöjä osoitteeseen /books
, jota seuraa kirjan tunnuksen määrittelevä polkumuuttuja. Metodi palauttaa polkumuuttujan arvoa vastaavan olion tietokannasta JSON
-muotoisena.
@GetMapping("/books/{id}")
@ResponseBody
public Book getBook(@PathVariable Long id) {
return bookRepository.getOne(id);
}
Alla oleva esimerkki sekä tallentaa olion tietokantaan että palauttaa tietokantaan tallennetun olion.
@PostMapping("/books")
@ResponseBody
public Book postBook(@RequestBody Book book) {
return bookRepository.save(book);
}
Yllä kuvattu kontrollerimetodi määrittelee polkua /books
kuuntelevan toiminnallisuuden. Metodille voi lähettää JSON-muotoista dataa, joka tallennetaan tietokantaan. Metodin vastaus on myös JSON-muotoinen — vastaus sisältää tietokantaan luodun kirjaolion tiedot (myös juuri luotuun kirjaan liittyvän pääavaimen).
Voimme lisätä annotaatioille @GetMapping
, @PostMapping
, jne lisätietoa metodin käsittelemän datan mediatyypistä. Attribuutti consumes
kertoo minkälaista dataa metodin kuuntelema osoite hyväksyy. Metodi voidaan rajoittaa vastaanottamaan JSON-muotoista dataa merkkijonolla "application/json"
. Vastaavasti metodille voidaan lisätä tieto sen tuottaman datan mediatyypistä. Attribuutti produces
kertoo tuotettavan mediatyypin. Alla määritelty metodi sekä vastaanottaa että tuottaa JSON-muotoista dataa.
@PostMapping(path="/books", consumes="application/json", produces="application/json")
@ResponseBody
public Book postBook(@RequestBody Book book) {
return bookStorage.create(book);
}
RestController
Kun toteutat omaa REST-rajapintaa, kontrolleriluokan annotaatioksi kannattaa määritellä annotaation @Controller
sijaan annotaatio @RestController
. Tällöin jokaiseen polkua kuuntelevaan metodiin tulee automaattisesti annotaatio @ResponseBody
sekä oikea mediatyyppi — tässä tapauksessa "application/json". Annotaatiota
Toteutetaan seuraavaksi kaikki tarvitut metodit kirjojen tallentamiseen. Kontrolleri hyödyntää erillistä luokkaa, joka tallentaa kirjaolioita tietokantaan ja tarjoaa tuen aiemmin määrittelemiemme books-osoitteiden ja pyyntöjen käsittelyyn — PUT-metodi on jätetty rajapinnasta pois.
// importit
@RestController
public class BookController {
@Autowired
private BookRepository bookRepository;
@GetMapping("/books")
public List<Book> getBooks() {
return bookRepository.findAll();
}
@GetMapping("/books/{id}")
public Book getBook(@PathVariable Long id) {
return bookRepository.getOne(id);
}
@DeleteMapping("/books/{id}")
public Book deleteBook(@PathVariable Long id) {
return bookRepository.deleteById(id);
}
@PostMapping("/books")
public Book postBook(@RequestBody Book book) {
return bookRepository.save(book);
}
}
Valmiin palvelun käyttäminen
Toisen sovelluksen tarjoamaan REST-rajapintaan pääsee kätevästi käsiksi RestTemplate-luokan avulla.
Kolmannen osapuolen rajapintojen käyttö kannattaa kapseloida omaksi palveluksi — alla olevassa esimerkissä kuvataan kirjojen hakemiseen tarkoitetun BookService
-luokan alku.
// importit
@Service
public class BookService {
private RestTemplate restTemplate;
public BookService() {
this.restTemplate = new RestTemplate();
}
// tänne luokan tarjoamat palvelut
}
Alla on kuvattuna RestTemplaten käyttö tiedon hakemiseen, päivittämiseen, poistaamiseen ja lisäämiseen. Esimerkeissä merkkijono osoite vastaa palvelimen osoitetta, esimerkiksi http://www.google.com
.
- GET osoitteeseen
/books
palauttaa kaikkien kirjojen tiedot tai osajoukon kirjojen tiedoista — riippuen toteutuksesta.
// kirjojen hakeminen
List<Book> books = restTemplate.getForObject("osoite/books", List.class);
- GET osoitteeseen
/books/{id}
, missä {id} on yksittäisen kirjan yksilöivä tunniste, palauttaa kyseisen kirjan tiedot.
// tunnuksella 5 määritellyn kirjan hakeminen
Book book = restTemplate.getForObject("osoite/books/{id}", Book.class, 5);
- PUT osoitteeseen
/books/{id}
, missä {id} on yksittäisen kirjan yksilöivä tunniste, muokkaa kyseisen kirjan tietoja tai lisää kirjan kyseiselle tunnukselle (toteutuksesta riippuen, lisäystä ei aina toteutettu). Kirjan tiedot lähetetään pyynnön rungossa.
// tunnuksella 5 määritellyn kirjan hakeminen
Book book = restTemplate.getForObject("osoite/books/{id}", Book.class, 5);
book.setName(book.getName() + " - DO NOT BUY!");
// kirjan tietojen muokkaaminen
restTemplate.put("osoite/books/{id}", book, 5);
- DELETE osoitteeseen
/books/{id}
poistaa kirjan tietyllä tunnuksella.
// tunnuksella 32 määritellyn kirjan poistaminen
restTemplate.delete("osoite/books/{id}", 32);
- POST osoitteeseen
/books
luo uuden kirjan pyynnön rungossa lähetettävän datan pohjalta. Palvelun vastuulla on päättää kirjalle tunnus.
Book book = new Book();
book.setName("Harry Potter and the Goblet of Fire");
// uuden kirjan lisääminen
book = restTemplate.postForObject("osoite/books", book, Book.class);
Usein sovellukset hyödyntävät kolmannen osapuolen tarjoamaa palvelua omien toiminnallisuuksiensa toteuttamiseen.
REST-toteutuksen kypsyystasot
Martin Fowler käsittelee artikkelissaan Richardson Maturity Model REST-rajapintojen kypsyyttä. Richardson Maturity Model (RMM) jaottelee REST-toteutuksen kolmeen tasoon, joista kukin tarkentaa toteutusta.
Aloituspiste on tason 0 palvelut, joita ei pidetä REST-palveluina. Näissä palveluissa HTTP-protokollaa käytetään lähinnä väylänä viestien lähettämiseen ja vastaanottamiseen, ja HTTP-protokollan käyttötapaan ei juurikaan oteta kantaa. Esimerkki tason 0 palvelusta on yksittäinen kontrollerimetodi, joka päättelee toteutettavan toiminnallisuuden pyynnössä olevan sisällön perusteella.
Tason 1 palvelut käsittelevät palveluita resursseina. Resurssit kuvataan palvelun osoitteena (esimerkiksi /books
-resurssi sisältää kirjoja), ja resursseja voidaan hakea tunnisteiden perusteella (esim. /books/nimi
). Edelliseen tasoon verrattuna käytössä on nyt konkreettisia resursseja; olio-ohjelmoijan kannalta näitä voidaan pitää myös olioina joilla on tila.
Tasolla 2 resurssien käsittelyyn käytetään kuvaavia HTTP-pyyntötyyppejä. Esimerkiksi resurssin pyyntö tapahtuu GET-metodilla, ja resurssin tilan muokkaaminen esimerkiksi PUT, POST, tai DELETE-metodilla. Näiden lisäksi palvelun vastaukset kuvaavat tapahtuneita toimintoja. Esimerkiksi jos palvelu luo resurssin, vastauksen tulee olla statuskoodi 201
, joka viestittää selaimelle resurssin luomisen onnistumisesta. Oleellista tällä tasolla on pyyntötyyppien erottaminen sen perusteella että muokkaavatko ne palvelimen dataa vai ei (GET vs. muut).
Kolmas taso sisältää tasot 1 ja 2, mutta lisää käyttäjälle mahdollisuuden ymmärtää palvelun tarjoama toiminnallisuus palvelimen vastausten perusteella. HATEOAS määrittelee miten web-resursseja tulisi löytää webistä.
Roy Fielding kokee vain tason 3 sovelluksen oikeana REST-sovelluksena. Ohjelmistosuunnittelun näkökulmasta jokainen taso parantaa sovelluksen ylläpidettävyyttä — Level 1 tackles the question of handling complexity by using divide and conquer, breaking a large service endpoint down into multiple resources; Level 2 introduces a standard set of verbs so that we handle similar situations in the same way, removing unnecessary variation; Level 3 introduces discoverability, providing a way of making a protocol more self-documenting. (lähde)
Huom! Sovellusta suunniteltaessa ja toteuttaessa ei tule olettaa että RMM-tason 3 sovellus olisi parempi kuin RMM-tason 2 sovellus. Sovellus voi olla huono riippumatta toteutetusta REST-rajapinnan muodosta — jossain tapauksissa rajapintaa ei oikeasti edes tarvita; asiakkaan tarpeet ja toiveet määräävät mitä sovelluskehittäjän kannattaa tehdä.
Spring Data REST
Spring-sovelluskehys sisältää projektin Spring Data REST, jonka avulla REST-palveluiden tekeminen helpottuu merkittävästi. Lisäämällä projektin pom.xml
-konfiguraatioon riippuvuus spring-boot-starter-data-rest
saamme Spring Boot-paketoidun version kyseisestä projektista käyttöömme.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
Kun riippuvuus lisätään projektiin, Repository
-rajapinnat tarjoavat automaattisesti REST-rajapinnan, jonka kautta resursseihin pääsee käsiksi. Riippuvuus tekee muutakin, kuten ottaa käyttöön rajapinnan käyttäjän elämää helpottavan HAL-selaimen — tästä esimerkki osoitteessa https://haltalk.herokuapp.com.
REST-rajapinta luodaan oletuksena sovelluksen juureen, joka ei aina ole tilanteena ideaali. Spring Data REST-projektin konfiguraatiota voi muokata erillisen RepositoryRestMvcConfiguration-luokan kautta. Alla olevassa esimerkissä REST-rajapinta luodaan osoitteen /api/v1
-alle. Annotaatio @Component
kertoo Springille että luokka tulee ladata käyttöön käynnistysvaiheessa; rajapinta kertoo mistä luokasta on kyse.
// pakkaus ja importit
@Component
public class CustomizedRestMvcConfiguration extends RepositoryRestConfigurerAdapter {
@Override
public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config) {
config.setBasePath("/api/v1");
}
}
Nyt jos sovelluksessa on entiteetti Book
sekä siihen sopiva BookRepository
, on Spring Data REST-rajapinta osoitteessa /api/v1/books
.
Sovelluksen kehittäjä harvemmin haluaa kaikkia HTTP-protokollan metodeja kaikkien käyttöön. Käytössä olevien metodien rajaaminen onnistuu käytettävää Repository
-rajapintaa muokkaamalla. Alla olevassa esimerkissä BookRepository
-rajapinnan olioita ei pysty poistamaan automaattisesti luodun REST-rajapinnan yli.
// pakkaus
import wad.domain.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RestResource;
public interface BookRepository extends JpaRepository<Message, Long> {
@RestResource(exported = false)
@Override
public void delete(Long id);
}
Spring Data REST ja RestTemplate
Spring Data RESTin avulla luotavien rajapintojen hyödyntäminen onnistuu RestTemplaten avulla. Esimerkiksi yllä luotavasta rajapinnasta voidaan hakea Resource
-olioita, jotka sisältävät kirjoja. RestTemplaten metodi exchange palauttaa vastausentiteetin, mikä sisältää hakemamme olion tiedot. Kyselyn mukana annettava ParameterizedTypeReference
taas kertoo minkälaiseksi olioksi vastaus tulee muuntaa.
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Resource<Book>> response =
restTemplate.exchange("osoite/books/1", // osoite
HttpMethod.GET, // metodi
null, // pyynnön runko; tässä tyhjä
new ParameterizedTypeReference<Resource<Book>>() {}); // vastaustyyppi
if (response.getStatusCode() == HttpStatus.OK) {
Resource<Book> resource = response.getBody();
Book book = resource.getContent();
}