Play Framework #
Di antara framework web Java, Play menempati posisi yang sangat berbeda dari Spring Boot maupun Vaadin. Play dirancang dari awal dengan satu keyakinan: server web modern tidak seharusnya menggunakan model thread-per-request. Alih-alih mengalokasikan satu thread untuk setiap request yang masuk dan membiarkannya terblokir saat menunggu database atau layanan eksternal, Play menggunakan model asynchronous non-blocking berbasis Akka. Hasilnya adalah server yang bisa menangani puluhan ribu koneksi bersamaan dengan thread pool yang jauh lebih kecil dari model tradisional. Play juga terkenal dengan developer experience-nya: hot reload yang benar-benar bekerja, error message yang informatif langsung di browser, dan konfigurasi yang minimal. Bagi tim yang membangun REST API berkinerja tinggi atau layanan yang sangat I/O-bound, Play adalah alternatif yang layak dipertimbangkan di samping ekosistem Spring.
Arsitektur Non-Blocking Play #
Perbedaan fundamental antara Play dan Spring MVC (atau framework servlet-based lainnya) adalah cara keduanya menangani request yang membutuhkan waktu — seperti query database atau panggilan ke API eksternal.
flowchart TD
subgraph BLOCKING[Spring MVC — Thread-per-Request]
R1[Request 1] --> T1[Thread 1\nterblokir menunggu DB]
R2[Request 2] --> T2[Thread 2\nterblokir menunggu DB]
R3[Request 3] --> T3[Thread 3\nterblokir menunggu DB]
R4[Request 4] --> WAIT[Antri...\nthread habis]
end
subgraph NONBLOCKING[Play — Non-Blocking]
R5[Request 1] --> EL[Event Loop\n4 thread]
R6[Request 2] --> EL
R7[Request 3] --> EL
R8[Request 4] --> EL
EL -->|I/O selesai| RESP[Response dikirim]
endDalam model Play, ketika sebuah action perlu menunggu hasil dari database, thread tidak terblokir — ia dikembalikan ke pool untuk menangani request lain. Ketika hasil database sudah siap, callback dijadwalkan untuk memproses respons. Ini diekspresikan dalam kode melalui CompletionStage<Result> — versi Java dari Promise/Future.
sequenceDiagram
participant Client
participant Play as Play Server
participant DB as Database
Client->>Play: GET /produk/1
Play->>DB: query async (thread bebas melayani request lain)
Note over Play: thread melayani request 2, 3, 4...
DB-->>Play: hasil query tersedia
Play->>Play: callback dieksekusi
Play-->>Client: 200 OK JSONSetup Project #
Play menggunakan sbt (Scala Build Tool) sebagai build tool utamanya, meski kamu menulis kode Java. Ini adalah salah satu hal yang sering mengejutkan developer Java baru mengenal Play.
# Install sbt terlebih dahulu
# Di macOS
brew install sbt
# Di Ubuntu/Debian
echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | sudo tee /etc/apt/sources.list.d/sbt.list
sudo apt-get update && sudo apt-get install sbt
# Buat project Play baru dari template Java
sbt new playframework/play-java-seed.g8
# Ikuti prompt — masuk ke direktori project
cd nama-project
sbt run # jalankan dengan hot reload
Struktur direktori project Play:
nama-project/
├── app/
│ ├── controllers/ ← action handler HTTP
│ │ └── HomeController.java
│ ├── models/ ← model data dan entity
│ └── views/ ← template Twirl (HTML)
│ └── index.scala.html
├── conf/
│ ├── application.conf ← konfigurasi utama (HOCON format)
│ ├── routes ← definisi routing
│ └── logback.xml
├── public/ ← asset statis
│ ├── css/
│ ├── js/
│ └── images/
├── test/ ← test files
└── build.sbt ← konfigurasi build
File build.sbt untuk project Java:
name := "toko-online"
organization := "com.contoh"
version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayJava)
scalaVersion := "2.13.14"
libraryDependencies ++= Seq(
// Play bawaan
guice, // dependency injection
javaJdbc, // JDBC support
evolutions, // database migration
// Database
"io.ebean" % "ebean" % "13.25.0",
"com.h2database" % "h2" % "2.2.224",
"org.postgresql" % "postgresql" % "42.7.3",
// Testing
"org.assertj" % "assertj-core" % "3.25.3" % Test
)
File conf/application.conf (format HOCON — lebih ekspresif dari properties):
# Nama aplikasi
play.application.name = "Toko Online"
play.http.secret.key = "changeme-ganti-di-production-dengan-nilai-acak-panjang"
# Database
db.default.driver = org.postgresql.Driver
db.default.url = "jdbc:postgresql://localhost:5432/tokoonline"
db.default.username = postgres
db.default.password = "rahasia"
# Connection pool
db.default.hikaricp.maximumPoolSize = 10
db.default.hikaricp.minimumIdle = 2
# Ebean ORM
ebean.default = ["models.*"]
# Evolutions (database migration)
play.evolutions.enabled = true
play.evolutions.autoApply = false # false di production — apply manual
# Logging
logger.root = ERROR
logger.play = INFO
logger.application = DEBUG
# Allowed hosts (security)
play.filters.hosts {
allowed = ["localhost", "contoh.com"]
}
Routing #
Play menggunakan file conf/routes untuk mendefinisikan semua route secara deklaratif. Format: METHOD URL Controller.action.
# conf/routes
# Halaman statis
GET / controllers.HomeController.index()
# REST API Produk
GET /api/produk controllers.ProdukController.semuaProduk()
POST /api/produk controllers.ProdukController.buatProduk()
GET /api/produk/:id controllers.ProdukController.cariProduk(id: Long)
PUT /api/produk/:id controllers.ProdukController.perbaruiProduk(id: Long)
DELETE /api/produk/:id controllers.ProdukController.hapusProduk(id: Long)
# Query parameter — GET /api/produk/cari?kata=laptop&halaman=2
GET /api/produk/cari controllers.ProdukController.cari(kata: String, halaman: Int ?= 1)
# Path parameter dengan regex constraint
GET /api/produk/$id<[0-9]+> controllers.ProdukController.cariProduk(id: Long)
# Asset statis
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# WebSocket
GET /ws/notifikasi controllers.WebSocketController.notifikasi()
Beberapa poin penting tentang routing Play:
- Route dievaluasi dari atas ke bawah — urutan penting jika ada route yang overlapping.
- Parameter dengan
?=adalah parameter opsional dengan nilai default. - Routing diverifikasi pada waktu kompilasi — typo di nama controller atau action akan menjadi compile error.
- Route bisa digunakan untuk generate URL di kode:
routes.ProdukController.cariProduk(42).
Controller dan Action #
Controller di Play adalah class biasa yang di-inject menggunakan Guice (dependency injection bawaan Play). Setiap action adalah method yang mengembalikan Result (sync) atau CompletionStage<Result> (async).
Action Synchronous #
package controllers;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;
import javax.inject.Inject;
import javax.inject.Singleton;
// @Singleton — satu instance untuk semua request (stateless)
// JANGAN simpan state di field — akan ada race condition
@Singleton
public class HomeController extends Controller {
// Action synchronous — cocok untuk operasi ringan yang tidak I/O bound
public Result index() {
return ok("Selamat datang di Toko Online API");
}
// Mengembalikan JSON
public Result status() {
com.fasterxml.jackson.databind.node.ObjectNode json =
play.libs.Json.newObject();
json.put("status", "ok");
json.put("timestamp", System.currentTimeMillis());
return ok(json);
}
// Mengakses request context
public Result info(Http.Request request) {
String userAgent = request.header("User-Agent").orElse("unknown");
String remoteAddress = request.remoteAddress();
return ok("UA: " + userAgent + " | IP: " + remoteAddress);
}
}
Action Asynchronous — Non-Blocking #
package controllers;
import play.libs.concurrent.HttpExecutionContext;
import play.mvc.Controller;
import play.mvc.Http;
import play.mvc.Result;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@Singleton
public class ProdukController extends Controller {
private final ProdukService produkService;
private final HttpExecutionContext httpExecutionContext;
@Inject
public ProdukController(ProdukService produkService,
HttpExecutionContext httpExecutionContext) {
this.produkService = produkService;
this.httpExecutionContext = httpExecutionContext;
}
// ✗ ANTI-PATTERN: blocking call dalam action — memblokir thread Play
public Result semuaProdukBlocking() {
var produk = produkService.semuaProdukBlocking(); // blokir thread!
return ok(play.libs.Json.toJson(produk));
}
// ✓ BENAR: async action — thread tidak terblokir
public CompletionStage<Result> semuaProduk() {
return produkService.semuaProdukAsync()
.thenApplyAsync(
produk -> ok(play.libs.Json.toJson(produk)),
httpExecutionContext.current() // jalankan di Play HTTP context
);
}
// GET /api/produk/:id
public CompletionStage<Result> cariProduk(Long id) {
return produkService.cariByIdAsync(id)
.thenApplyAsync(produkOpt -> {
if (produkOpt.isEmpty()) {
return notFound(errorJson("Produk dengan ID " + id + " tidak ditemukan"));
}
return ok(play.libs.Json.toJson(produkOpt.get()));
}, httpExecutionContext.current());
}
// POST /api/produk
public CompletionStage<Result> buatProduk(Http.Request request) {
com.fasterxml.jackson.databind.JsonNode body = request.body().asJson();
if (body == null) {
return CompletableFuture.completedFuture(
badRequest(errorJson("Request body harus JSON"))
);
}
// Validasi manual dari JSON
String nama = body.path("nama").asText();
double harga = body.path("harga").asDouble();
if (nama.isBlank()) {
return CompletableFuture.completedFuture(
badRequest(errorJson("Nama produk tidak boleh kosong"))
);
}
return produkService.simpanAsync(nama, harga)
.thenApplyAsync(
produk -> created(play.libs.Json.toJson(produk)),
httpExecutionContext.current()
);
}
// DELETE /api/produk/:id
public CompletionStage<Result> hapusProduk(Long id) {
return produkService.hapusAsync(id)
.thenApplyAsync(berhasil -> {
if (!berhasil) {
return notFound(errorJson("Produk tidak ditemukan"));
}
return noContent(); // 204 No Content
}, httpExecutionContext.current());
}
// Helper — buat JSON error response yang konsisten
private com.fasterxml.jackson.databind.node.ObjectNode errorJson(String pesan) {
com.fasterxml.jackson.databind.node.ObjectNode error = play.libs.Json.newObject();
error.put("error", pesan);
error.put("timestamp", System.currentTimeMillis());
return error;
}
}
JSON Handling #
Play menyertakan Jackson dan menyediakan API wrapper yang nyaman melalui play.libs.Json.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import play.libs.Json;
public class JsonDemo {
// Konversi objek Java ke JSON
public static void objekKeJson() {
Produk produk = new Produk("Laptop", 15000000.0, 5);
// Otomatis serialisasi field public / getter
JsonNode json = Json.toJson(produk);
System.out.println(json.toString());
// {"nama":"Laptop","harga":15000000.0,"stok":5}
}
// Konversi JSON ke objek Java
public static void jsonKeObjek(String jsonString) {
JsonNode json = Json.parse(jsonString);
Produk produk = Json.fromJson(json, Produk.class);
}
// Buat JSON secara programatis
public static ObjectNode buatJsonResponse(boolean sukses, String pesan) {
ObjectNode root = Json.newObject();
root.put("sukses", sukses);
root.put("pesan", pesan);
root.put("timestamp", System.currentTimeMillis());
ArrayNode tags = root.putArray("tags");
tags.add("api");
tags.add("v1");
return root;
}
// Akses nilai dari JSON
public static void aksesJson(JsonNode json) {
// path() — tidak throw exception jika key tidak ada, return MissingNode
String nama = json.path("nama").asText("default");
double harga = json.path("harga").asDouble(0);
boolean aktif = json.path("aktif").asBoolean(true);
// get() — return null jika key tidak ada
JsonNode stokNode = json.get("stok");
if (stokNode != null && !stokNode.isNull()) {
int stok = stokNode.asInt();
}
// Akses array
JsonNode tags = json.path("tags");
if (tags.isArray()) {
for (JsonNode tag : tags) {
System.out.println("Tag: " + tag.asText());
}
}
}
}
Reads dan Writes — Type-Safe JSON Mapping #
Untuk mapping yang lebih robust dengan validasi, definisikan Reads dan Writes:
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
// DTO dengan anotasi Jackson untuk kontrol serialisasi
@JsonIgnoreProperties(ignoreUnknown = true) // abaikan field yang tidak dikenal
public class ProdukRequest {
@JsonProperty("nama")
private final String nama;
@JsonProperty("harga")
private final double harga;
@JsonProperty("stok")
private final int stok;
// @JsonCreator menandai constructor ini untuk deserialisasi
@JsonCreator
public ProdukRequest(
@JsonProperty("nama") String nama,
@JsonProperty("harga") double harga,
@JsonProperty("stok") int stok) {
this.nama = nama;
this.harga = harga;
this.stok = stok;
}
public String getNama() { return nama; }
public double getHarga() { return harga; }
public int getStok() { return stok; }
}
// Penggunaan di controller
public CompletionStage<Result> buatProdukTypeSafe(Http.Request request) {
// fromJson otomatis gunakan anotasi Jackson
ProdukRequest produkReq = Json.fromJson(
request.body().asJson(), ProdukRequest.class
);
if (produkReq.getNama() == null || produkReq.getNama().isBlank()) {
return CompletableFuture.completedFuture(
badRequest(errorJson("nama wajib diisi"))
);
}
return produkService.simpanAsync(
produkReq.getNama(), produkReq.getHarga())
.thenApplyAsync(
p -> created(Json.toJson(p)),
httpExecutionContext.current()
);
}
Form Validation #
Play menyediakan Form API untuk validasi request form (baik JSON maupun form-encoded):
import play.data.Form;
import play.data.FormFactory;
import play.data.validation.Constraints;
// Data class untuk form binding
public class ProdukForm {
@Constraints.Required(message = "Nama produk wajib diisi")
@Constraints.MinLength(value = 3, message = "Nama minimal 3 karakter")
@Constraints.MaxLength(value = 100, message = "Nama maksimal 100 karakter")
public String nama;
@Constraints.Required(message = "Harga wajib diisi")
@Constraints.Min(value = 1, message = "Harga minimal 1")
public Double harga;
@Constraints.Min(value = 0, message = "Stok tidak boleh negatif")
public Integer stok = 0;
}
// Controller dengan FormFactory
@Singleton
public class ProdukFormController extends Controller {
private final FormFactory formFactory;
private final ProdukService produkService;
private final HttpExecutionContext httpExecutionContext;
@Inject
public ProdukFormController(FormFactory formFactory,
ProdukService produkService,
HttpExecutionContext httpExecutionContext) {
this.formFactory = formFactory;
this.produkService = produkService;
this.httpExecutionContext = httpExecutionContext;
}
public CompletionStage<Result> buatProduk(Http.Request request) {
Form<ProdukForm> form = formFactory.form(ProdukForm.class)
.bindFromRequest(request); // bind dari JSON body atau form-encoded
if (form.hasErrors()) {
// Kembalikan semua error dalam format JSON
return CompletableFuture.completedFuture(
badRequest(form.errorsAsJson())
);
}
ProdukForm data = form.get();
return produkService.simpanAsync(data.nama, data.harga)
.thenApplyAsync(
produk -> created(Json.toJson(produk)),
httpExecutionContext.current()
);
}
}
Filter dan Middleware #
Filter di Play adalah mekanisme untuk menerapkan logika yang dijalankan untuk setiap request — mirip middleware di framework lain. Cocok untuk logging, autentikasi, CORS, dan rate limiting.
import play.mvc.EssentialAction;
import play.mvc.EssentialFilter;
import play.mvc.Http;
import javax.inject.Inject;
import java.util.concurrent.Executor;
// Filter untuk logging setiap request
public class LoggingFilter extends EssentialFilter {
private final Executor executor;
@Inject
public LoggingFilter(Executor executor) {
this.executor = executor;
}
@Override
public EssentialAction apply(EssentialAction next) {
return EssentialAction.of(request -> {
long mulai = System.currentTimeMillis();
return next.apply(request).map(result -> {
long durasi = System.currentTimeMillis() - mulai;
System.out.printf(
"[%s] %s %s — %d (%dms)%n",
java.time.LocalDateTime.now(),
request.method(),
request.uri(),
result.status(),
durasi
);
return result;
}, executor);
});
}
}
// Filter untuk autentikasi Bearer token
public class AuthFilter extends EssentialFilter {
private final Executor executor;
private static final String TOKEN_VALID = "secret-token-production";
@Inject
public AuthFilter(Executor executor) {
this.executor = executor;
}
@Override
public EssentialAction apply(EssentialAction next) {
return EssentialAction.of(request -> {
// Lewati filter untuk route publik
if (isPublicRoute(request)) {
return next.apply(request);
}
// Periksa Authorization header
String authHeader = request.header(Http.HeaderNames.AUTHORIZATION)
.orElse("");
if (!authHeader.startsWith("Bearer ") ||
!authHeader.substring(7).equals(TOKEN_VALID)) {
return akka.stream.javadsl.Source.single(
play.mvc.Results.unauthorized(
Json.toJson(java.util.Map.of("error", "Token tidak valid"))
).body()
);
// Lebih idiomatik — kembalikan Result langsung:
}
return next.apply(request);
});
}
private boolean isPublicRoute(Http.RequestHeader request) {
String path = request.path();
return path.equals("/") || path.startsWith("/public");
}
}
Daftarkan filter di conf/application.conf:
play.filters.enabled += "filters.LoggingFilter"
play.filters.enabled += "filters.AuthFilter"
# Filter bawaan Play yang berguna
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.enabled += "play.filters.gzip.GzipFilter"
play.filters.enabled += "play.filters.csrf.CSRFFilter"
# Konfigurasi CORS
play.filters.cors {
allowedOrigins = ["https://frontend.contoh.com", "http://localhost:3000"]
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Authorization"]
}
Akses Database dengan Ebean #
Ebean adalah ORM yang direkomendasikan untuk Play Java. Ia lebih ringan dari Hibernate dan punya API yang lebih sederhana.
package models;
import io.ebean.Model;
import io.ebean.annotation.WhenCreated;
import io.ebean.annotation.WhenModified;
import javax.persistence.*;
import java.time.Instant;
import java.math.BigDecimal;
// Entity Ebean
@Entity
@Table(name = "produk")
public class Produk extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(nullable = false, length = 100)
public String nama;
@Column(nullable = false, precision = 15, scale = 2)
public BigDecimal harga;
@Column(nullable = false)
public int stok;
@Column(nullable = false)
public boolean aktif = true;
@WhenCreated
public Instant dibuatPada;
@WhenModified
public Instant diubahPada;
// Finder — query builder untuk model ini
public static final Finder<Long, Produk> find =
new Finder<>(Produk.class);
}
package services;
import io.ebean.DB;
import models.Produk;
import javax.inject.Singleton;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@Singleton
public class ProdukService {
// Jalankan query database di thread terpisah agar tidak memblokir event loop
private static final java.util.concurrent.Executor dbExecutor =
java.util.concurrent.Executors.newFixedThreadPool(10);
// Ambil semua produk (async)
public CompletionStage<List<Produk>> semuaProdukAsync() {
return CompletableFuture.supplyAsync(() ->
Produk.find.query()
.where()
.eq("aktif", true)
.orderBy("nama asc")
.findList(),
dbExecutor
);
}
// Cari berdasarkan ID
public CompletionStage<Optional<Produk>> cariByIdAsync(Long id) {
return CompletableFuture.supplyAsync(() ->
Optional.ofNullable(Produk.find.byId(id)),
dbExecutor
);
}
// Cari berdasarkan nama (partial match)
public CompletionStage<List<Produk>> cariByNamaAsync(String kata) {
return CompletableFuture.supplyAsync(() ->
Produk.find.query()
.where()
.ilike("nama", "%" + kata + "%")
.findList(),
dbExecutor
);
}
// Simpan produk baru
public CompletionStage<Produk> simpanAsync(String nama, double harga) {
return CompletableFuture.supplyAsync(() -> {
Produk produk = new Produk();
produk.nama = nama;
produk.harga = BigDecimal.valueOf(harga);
produk.stok = 0;
produk.save(); // Ebean INSERT
return produk;
}, dbExecutor);
}
// Perbarui produk
public CompletionStage<Optional<Produk>> perbaruiAsync(Long id, String nama, double harga) {
return CompletableFuture.supplyAsync(() -> {
Produk produk = Produk.find.byId(id);
if (produk == null) return Optional.empty();
produk.nama = nama;
produk.harga = BigDecimal.valueOf(harga);
produk.update(); // Ebean UPDATE
return Optional.of(produk);
}, dbExecutor);
}
// Hapus produk (soft delete)
public CompletionStage<Boolean> hapusAsync(Long id) {
return CompletableFuture.supplyAsync(() -> {
Produk produk = Produk.find.byId(id);
if (produk == null) return false;
produk.aktif = false;
produk.update();
return true;
}, dbExecutor);
}
// Query dengan raw SQL menggunakan named query
public CompletionStage<List<Produk>> produkStokMenipis(int batasStok) {
return CompletableFuture.supplyAsync(() ->
DB.find(Produk.class)
.setRawSql(io.ebean.RawSqlBuilder
.parse("SELECT id, nama, stok FROM produk WHERE stok <= :batas AND aktif = true")
.create())
.setParameter("batas", batasStok)
.findList(),
dbExecutor
);
}
}
Database Evolution #
Play menggunakan Evolutions untuk mengelola skema database. File SQL disimpan di conf/evolutions/default/:
-- conf/evolutions/default/1.sql
-- !Ups (jalankan saat apply)
CREATE TABLE produk (
id BIGSERIAL PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
harga NUMERIC(15, 2) NOT NULL,
stok INTEGER NOT NULL DEFAULT 0,
aktif BOOLEAN NOT NULL DEFAULT TRUE,
dibuat_pada TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
diubah_pada TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_produk_nama ON produk (nama);
CREATE INDEX idx_produk_aktif ON produk (aktif);
-- !Downs (jalankan saat revert)
DROP TABLE IF EXISTS produk;
-- conf/evolutions/default/2.sql
-- !Ups
ALTER TABLE produk ADD COLUMN kategori VARCHAR(50);
CREATE INDEX idx_produk_kategori ON produk (kategori);
-- !Downs
ALTER TABLE produk DROP COLUMN kategori;
WebSocket #
Play mendukung WebSocket natively. Ini salah satu kelebihan Play dibanding Spring MVC versi lama — WebSocket adalah first-class citizen karena arsitektur Akka Streams di bawahnya.
import akka.stream.javadsl.Flow;
import play.mvc.WebSocket;
import javax.inject.Inject;
import javax.inject.Singleton;
@Singleton
public class WebSocketController extends Controller {
// WebSocket echo sederhana — kirim kembali apa yang diterima
public WebSocket echo() {
return WebSocket.Text.accept(request ->
Flow.<String>create() // terima pesan String
.map(pesan -> "Echo: " + pesan) // proses
// kembalikan ke client
);
}
// WebSocket dengan logika lebih kompleks
public WebSocket notifikasi() {
return WebSocket.Text.accept(request -> {
// Ambil user ID dari query parameter
String userId = request.queryString("userId")
.map(values -> values.get(0))
.orElse("anonim");
System.out.println("WebSocket terhubung: user=" + userId);
return Flow.<String>create()
.map(pesan -> {
System.out.println("Pesan dari " + userId + ": " + pesan);
// Proses pesan dan kirim respons
return "Server menerima: " + pesan + " (dari user: " + userId + ")";
});
});
}
}
Pengujian #
Play menyediakan play.test API untuk menjalankan test dengan atau tanpa server yang berjalan.
import org.junit.Test;
import play.Application;
import play.inject.guice.GuiceApplicationBuilder;
import play.mvc.Http;
import play.mvc.Result;
import play.test.Helpers;
import play.test.WithApplication;
import static org.assertj.core.api.Assertions.assertThat;
import static play.test.Helpers.*;
import static play.mvc.Http.Status.*;
public class ProdukControllerTest extends WithApplication {
// WithApplication menyediakan instance Application untuk setiap test
@Override
protected Application provideApplication() {
return new GuiceApplicationBuilder()
.configure("db.default.url", "jdbc:h2:mem:test;MODE=PostgreSQL")
.configure("db.default.driver", "org.h2.Driver")
.configure("play.evolutions.autoApply", "true")
.build();
}
@Test
public void semuaProduk_tanpaData_kembalikanArrayKosong() {
Http.RequestBuilder request = new Http.RequestBuilder()
.method(GET)
.uri("/api/produk");
Result result = route(app, request);
assertThat(result.status()).isEqualTo(OK);
assertThat(contentAsString(result)).contains("[]");
}
@Test
public void buatProduk_denganDataValid_kembalikan201() {
String body = """
{
"nama": "Laptop Gaming",
"harga": 15000000,
"stok": 5
}
""";
Http.RequestBuilder request = new Http.RequestBuilder()
.method(POST)
.uri("/api/produk")
.header("Content-Type", "application/json")
.bodyText(body);
Result result = route(app, request);
assertThat(result.status()).isEqualTo(CREATED);
com.fasterxml.jackson.databind.JsonNode json =
play.libs.Json.parse(contentAsString(result));
assertThat(json.path("nama").asText()).isEqualTo("Laptop Gaming");
assertThat(json.path("id").asLong()).isGreaterThan(0);
}
@Test
public void cariProduk_idTidakAda_kembalikan404() {
Http.RequestBuilder request = new Http.RequestBuilder()
.method(GET)
.uri("/api/produk/999");
Result result = route(app, request);
assertThat(result.status()).isEqualTo(NOT_FOUND);
}
}
Kapan Menggunakan Play dan Kapan Tidak #
GUNAKAN PLAY FRAMEWORK JIKA:
✓ Membangun REST API atau layanan yang sangat I/O-bound
✓ Butuh throughput tinggi dengan resource thread yang efisien
✓ Tim familiar dengan model async/reactive programming
✓ Butuh hot reload yang cepat di development
✓ WebSocket dan streaming adalah kebutuhan utama
✓ Tidak butuh ekosistem Spring (security, batch, dll)
✓ Ingin arsitektur yang lebih lightweight dari Spring Boot
PERTIMBANGKAN ALTERNATIF JIKA:
✗ Tim lebih familiar dengan Spring — learning curve async tidak sepadan
✗ Butuh ekosistem enterprise lengkap → Spring Boot jauh lebih kaya
✗ Aplikasi banyak operasi CPU-bound, bukan I/O-bound → async tidak banyak membantu
✗ Tim Java tidak nyaman dengan sbt sebagai build tool
✗ Butuh integrasi JPA/Hibernate yang mature → Spring Data lebih lengkap
✗ Komunitas dan dokumentasi ekosistem → Spring jauh lebih besar
flowchart TD
A{Throughput tinggi\nI/O-bound?} -- Ya --> B{Tim familiar\ndengan async?}
A -- Tidak --> SPRING[Spring Boot]
B -- Ya --> C{Butuh ekosistem\nenterprise lengkap?}
B -- Tidak --> D{Mau belajar\nasync model?}
C -- Ya --> SPRING
C -- Tidak --> PLAY[Play Framework]
D -- Ya --> PLAY
D -- Tidak --> SPRINGRingkasan #
- Play adalah framework non-blocking — action yang butuh I/O harus mengembalikan
CompletionStage<Result>, bukanResultlangsung. Jangan jalankan operasi blocking (query database synchronous, Thread.sleep) di thread event loop Play.- File
conf/routesadalah titik tunggal definisi routing — semua mapping URL ke controller action ada di sini, diverifikasi pada waktu kompilasi sehingga typo langsung terdeteksi.httpExecutionContext.current()harus digunakan sebagai executor di setiapthenApplyAsync()untuk memastikan callback berjalan di Play HTTP context yang benar, bukan di fork-join pool default Java.- Jalankan query database di thread pool terpisah (
dbExecutor) menggunakanCompletableFuture.supplyAsync()— Ebean tidak non-blocking secara native, jadi perlu dieksekusi di luar event loop.- Filter adalah cara idiomatik Play untuk middleware — logging, autentikasi, CORS, dan rate limiting semua diimplementasikan sebagai
EssentialFilterdan didaftarkan diapplication.conf.- Evolutions mengelola migrasi skema database — satu file SQL per versi dengan blok
!Upsdan!Downs. GunakanautoApply=falsedi production dan apply manual.conf/application.confmenggunakan format HOCON yang lebih ekspresif dari properties — mendukung inheritance, variable substitution, dan komentar dengan lebih baik.- Play paling cocok untuk REST API I/O-bound dengan throughput tinggi — jika tim lebih familiar dengan ekosistem Spring, manfaat Play tidak sebanding dengan learning curve-nya.