Play Framework

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]
    end

Dalam 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 JSON

Setup 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 --> SPRING

Ringkasan #

  • Play adalah framework non-blocking — action yang butuh I/O harus mengembalikan CompletionStage<Result>, bukan Result langsung. Jangan jalankan operasi blocking (query database synchronous, Thread.sleep) di thread event loop Play.
  • File conf/routes adalah 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 setiap thenApplyAsync() 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) menggunakan CompletableFuture.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 EssentialFilter dan didaftarkan di application.conf.
  • Evolutions mengelola migrasi skema database — satu file SQL per versi dengan blok !Ups dan !Downs. Gunakan autoApply=false di production dan apply manual.
  • conf/application.conf menggunakan 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.

← Sebelumnya: Vaadin   Berikutnya: Quarkus →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact