Command Palette

Search for a command to run...

[Spring Boot Basics] Kiến thức Java cần có trước khi học Spring Boot: OOP, Generics, Stream, Record và Annotation

Phần lớn người bỏ cuộc với Spring Boot không phải vì Spring. Họ bỏ cuộc vì phần Java nằm dưới nó — một List<Notifier> tự nhiên có sẵn nội dung, một JpaRepository<User, Long> có hai type parameter và không có method body, một @GetMapping trông như thể viết lại cả chương trình. Không cái nào trong đó là Spring. Tất cả đều là Java bình thường mà Spring dựa vào rất nhiều.

Bài này là danh sách kiến thức Java cần có cho cả series, và mỗi mục đều được dạy qua đúng chỗ Spring dùng tới nó. Mọi ví dụ bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64), dựa trên đúng các jar mà Spring Boot 4.1.1 resolve ra, nên output và lỗi compile là thứ toolchain thật sự in ra.

Spring Boot nằm trên sáu tính năng Java bình thường: interface, List, lambda, Optional, record và annotation

Hãy đọc nó như một checklist. Phần nào bạn thấy hiển nhiên thì bỏ qua; phần nào chưa rõ thì chính là chỗ Spring sẽ làm bạn rối về sau.

Series này xây dựng cái gì

Series dựng một REST API chạy trên relational database, làm từng lớp một: HTTP endpoint, tầng service, persistence bằng Spring Data JPA, validation, xử lý lỗi và test. Bài viết dành cho người đã viết và compile được Java nhưng chưa từng dùng framework nào.

Lựa chọnVì sao nó quan trọng ở đây
FrameworkSpring Boot 4.1.1kéo theo Spring Framework 7.0.9
Ngôn ngữJava 21 (LTS)có sẵn record, var, pattern matching, Stream.toList()
Build toolGradle, qua wrapper ./gradlewkhông cần cài Gradle; Maven sẽ được so sánh ở bài sau trong series
JDK tối thiểu17Boot 4.x không chạy được trên bản cũ hơn

Dòng cuối không phải nghe đồn. Các class file trong spring-boot-4.1.1.jar được compile ra class file major version 61, tức Java 17:

Bash
javap -v -cp spring-boot-4.1.1.jar org.springframework.boot.SpringApplication | grep 'major version'
Text
  major version: 61

Series dùng Java 21 vì đây là bản LTS hiện tại, và vì record với Stream.toList() bớt được rất nhiều dòng code thừa trong ví dụ.

Spring Framework và Spring Boot thực chất là gì, auto-configuration và starter làm gì, embedded server ở đâu ra — đó là nội dung bài tiếp theo. Bài này dừng ở phần Java.

Đây là bản đồ. Mỗi dòng là một phần bên dưới, cột phải là API của Spring sẽ vô nghĩa nếu thiếu nó.

Tính năng JavaBạn gặp nó ở đâu trong Spring
interface + polymorphismmột field của service khai báo theo interface, implementation được chọn ở nơi khác
List, Set, MapList<SomeInterface> chứa mọi implementation; Map<String, T> đánh theo tên
genericsJpaRepository<User, Long>, ResponseEntity<T>, Optional<T>
lambda + functional interfaceorElseThrow(() -> ...), config viết dạng cfg -> cfg.something()
Streambiến một list entity thành một list DTO
Optionalgiá trị trả về của findById
recordDTO cho request và response, bind từ JSON
annotation + reflection@Component, @GetMapping, @Entity — toàn bộ

Interface và polymorphism: hình dạng mà Spring mong đợi

Interface là một danh sách signature của method, không có hành vi. Class nào implement nó thì cam kết cung cấp phần hành vi đó. Điều duy nhất quan trọng với Spring là cái mà caller được lợi: caller có thể viết theo interface và không bao giờ phải nhắc tên một class cụ thể.

Java
interface Notifier {
    String channel();
    void send(String to, String message);
}
 
class EmailNotifier implements Notifier {
    public String channel() { return "email"; }
    public void send(String to, String message) {
        System.out.println("[email] to=" + to + " body=" + message);
    }
}
 
class SmsNotifier implements Notifier {
    public String channel() { return "sms"; }
    public void send(String to, String message) {
        System.out.println("[sms] to=" + to + " body=" + message);
    }
}

Caller khai báo interface và nhận nó qua constructor. Bên trong nó không có new EmailNotifier() ở bất cứ đâu:

Java
class OrderService {
    private final Notifier notifier;
 
    OrderService(Notifier notifier) {
        this.notifier = notifier;
    }
 
    void placeOrder(String customer, String sku) {
        System.out.println("order placed: " + sku + " (notifier=" + notifier.channel() + ")");
        notifier.send(customer, "Order " + sku + " confirmed");
    }
}

Polymorphism là nửa còn lại: cùng một chỗ gọi nhưng ra hành vi khác nhau tuỳ theo thứ được truyền vào.

Java
new OrderService(new EmailNotifier()).placeOrder("an@example.com", "SKU-1");
new OrderService(new SmsNotifier()).placeOrder("+84900000001", "SKU-2");
Text
order placed: SKU-1 (notifier=email)
[email] to=an@example.com body=Order SKU-1 confirmed
order placed: SKU-2 (notifier=sms)
[sms] to=+84900000001 body=Order SKU-2 confirmed

Caller phụ thuộc vào interface Notifier, phía sau là EmailNotifier và SmsNotifier, class cụ thể được chọn bên ngoài caller

Hãy nhìn kỹ hai nửa đó nằm ở đâu. Bên trong OrderService, type duy nhất được nhắc tên là Notifier. Bên ngoài nó, ai đó viết new EmailNotifier(). Khoảng trống ấy — caller khai báo một type, người khác cung cấp instance — là một lỗ hổng trong chương trình, và lấp nó một cách tự động chính là việc của dependency injection. Series sẽ nói kỹ về chuyện đó sau; điều cần nhớ lúc này là DI không phải một tính năng mới của ngôn ngữ, nó chỉ là câu trả lời máy móc cho một khoảng trống mà cách viết code này vốn đã có.

Ba quy tắc liên quan nên nắm rõ trước khi tới đó:

Cấu trúcGiữ được stateCó hành viMột class có thể có
interfacekhông (chỉ hằng số)method defaultstaticnhiều
abstract classcó, kể cả constructorđúng một superclass
recordcó, chỉ immutablecó, nhưng không thêm fieldbản thân nó đã ngầm là final

Chọn interface khi bạn dự đoán sẽ có nhiều hơn một implementation, hoặc khi muốn thay bằng một bản giả trong test. Chỉ dùng abstract class khi nhiều implementation thật sự dùng chung state.

Collections: List, Set và Map

Ba interface này phủ gần hết những việc một ứng dụng Spring làm với nhóm object.

InterfaceĐảm bảoImplementation thường dùngDùng ở đâu trong app Spring
List<E>có thứ tự, cho phép trùng, truy cập theo indexArrayListcác dòng repository trả về
Set<E>không trùng, không đảm bảo thứ tựHashSet, LinkedHashSetrole, tag, id duy nhất
Map<K,V>key ánh xạ sang value, mỗi key một entryHashMap, LinkedHashMapbảng tra cứu, request parameter
Java
List<String> roles = new ArrayList<>(List.of("ADMIN", "USER", "USER"));
Set<String> distinct = new HashSet<>(roles);
Map<String, Integer> limits = new HashMap<>();
limits.put("ADMIN", 1000);
limits.put("USER", 50);
 
System.out.println("list     = " + roles + "  size=" + roles.size());
System.out.println("set      = " + distinct + "  size=" + distinct.size());
System.out.println("map      = " + limits);
System.out.println("get USER = " + limits.get("USER"));
System.out.println("get NONE = " + limits.get("NONE"));
System.out.println("getOrDef = " + limits.getOrDefault("NONE", 0));
Text
list     = [ADMIN, USER, USER]  size=3
set      = [ADMIN, USER]  size=2
map      = {ADMIN=1000, USER=50}
get USER = 50
get NONE = null
getOrDef = 0

Hai hành vi trong output đó gây ra bug thật. Map.get trả về null khi không có key chứ không throw, và đó là lý do getOrDefault tồn tại. Còn Set đã âm thầm nuốt mất bản trùng USER — ba phần tử vào, hai phần tử ra.

Loại collection sẽ làm bạn bất ngờ trong Spring là một List theo type của interface. Khi nhiều class cùng implement Notifier, Spring có thể trao cho caller tất cả chúng trong một List<Notifier>, và một Map<String, Notifier> đánh theo tên. Phần Java của chuyện đó chẳng có gì đặc biệt — chỉ là một list các instance, tất cả được nhìn qua interface:

Java
List<Notifier> injected = List.of(new EmailNotifier(), new SmsNotifier());
Map<String, Notifier> byChannel = injected.stream()
        .collect(Collectors.toMap(Notifier::channel, Function.identity()));
 
System.out.println("byChannel keys = " + byChannel.keySet());
byChannel.get("sms").send("+84900000001", "ping");
Text
byChannel keys = [sms, email]
[sms] +84900000001 ping

Để ý thứ tự key: HashMap không giữ thứ tự thêm vào, và entry sms ra trước dù email được thêm trước. Nếu thứ tự quan trọng thì phải nói rõ bằng LinkedHashMap.

Một cái bẫy mà ai cũng dính ít nhất một lần. List.of(...), Map.of(...) và những thứ tương tự không phải đều mutable. Các factory method này trả về collection immutable:

Java
List.of("a").add("b");
Text
List.of is immutable -> java.lang.UnsupportedOperationException: null

Thông báo đúng là null — exception này không mang theo chi tiết nào. Khi một repository hay một library trao cho bạn một list, hãy copy bằng new ArrayList<>(list) trước khi sửa nó.

Generics: type parameter, bound và erasure

Type parameter là chỗ trống cho một type mà caller sẽ điền vào. List<String>List<E> với E được cố định thành String, và compiler sẽ từ chối mọi thứ khác:

Java
List<String> names = new ArrayList<>();
names.add(42);
Text
Unsafe.java:7: error: incompatible types: int cannot be converted to String
        names.add(42);
                  ^

Bạn sẽ hiếm khi tự viết type parameter, nhưng đọc chúng thì liên tục. Đây đúng là hình dạng Spring Data dùng, và trong đó chẳng có phép thuật nào — một interface với hai parameter, một cho entity và một cho id của nó:

Java
interface CrudRepository<T, ID> {
    T save(T entity);
    Optional<T> findById(ID id);
    List<T> findAll();
}
 
class UserRepository implements CrudRepository<User, Long> {
    private final Map<Long, User> rows = new LinkedHashMap<>();
 
    public User save(User entity) {
        rows.put(entity.id(), entity);
        return entity;
    }
 
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(rows.get(id));
    }
 
    public List<User> findAll() {
        return List.copyOf(rows.values());
    }
}
Text
findById(1)  = Optional[User[id=1, name=An]]
findById(99) = Optional.empty
findAll      = [User[id=1, name=An], User[id=2, name=Binh]]

So với bản thật. CrudRepository của Spring Data, decompile từ spring-data-commons 4.1.1, đúng bản mà Boot 4.1.1 resolve ra:

Text
public interface org.springframework.data.repository.CrudRepository<T, ID> extends org.springframework.data.repository.Repository<T, ID> {
  public abstract <S extends T> S save(S);
  public abstract java.util.Optional<T> findById(ID);
  public abstract boolean existsById(ID);
  public abstract java.lang.Iterable<T> findAll();
  public abstract long count();
  public abstract void deleteById(ID);

JpaRepository<User, Long> chính là thứ đó, sâu qua vài tầng interface. Nên khi bạn viết interface UserRepository extends JpaRepository<User, Long>, bạn đang nói entity là User, primary key là Long — và signature của mọi method kế thừa được viết lại theo đó. findById trả về Optional<User> và nhận vào Long, vì chính bạn đã khai như vậy.

ResponseEntity<T> đọc y hệt: parameter là type của body, nên compiler biết getBody() trả về gì mà không cần cast.

Java
ResponseEntity<User> response = ResponseEntity.ok(new User(1L, "An"));
String name = response.getBody().name();
System.out.println("status   : " + response.getStatusCode() + ", body name = " + name);
Text
status   : 200 OK, body name = An

Type parameter có bound thì hạn chế thứ được thay vào. <T extends Number> nghĩa là "bất kỳ type nào là Number", và chính điều đó cho phép thân method gọi các method của Number:

Java
static <T extends Number> double sum(List<T> values) {
    double total = 0;
    for (T n : values) total += n.doubleValue();
    return total;
}
Text
sum(int)     = 6.0
sum(double)  = 4.0

Type erasure, và chỗ duy nhất nó lộ ra

Generics tồn tại cho compiler. Sau khi compile, các type argument bị xoá, và lúc runtime chỉ còn lại List:

Java
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println("erasure: a.getClass() == b.getClass() -> " + (a.getClass() == b.getClass()));
System.out.println("erasure: both are " + a.getClass().getName());
Text
erasure: a.getClass() == b.getClass() -> true
erasure: both are java.util.ArrayList

Đó là lý do hai overload chỉ khác nhau ở type argument thì không compile được — sau erasure chúng là cùng một method:

Java
class Clash {
    void save(List<String> names) {}
    void save(List<Integer> ids) {}
}
Text
Clash.java:5: error: name clash: save(List<Integer>) and save(List<String>) have the same erasure
    void save(List<Integer> ids) {}
         ^

Erasure trở thành vấn đề của Spring khi framework phải deserialize vào một generic type. Chỉ với List.class, một HTTP client không thể biết các phần tử phải trở thành object User. Câu trả lời của Spring là ParameterizedTypeReference: bạn tạo một anonymous subclass, subclass đó ghi lại generic superclass của nó vào class file, và type sống sót:

Java
List<User> users = new ArrayList<>();
System.out.println("erased   : " + users.getClass().getName());
 
ParameterizedTypeReference<List<User>> ref = new ParameterizedTypeReference<>() {};
System.out.println("retained : " + ref.getType());
Text
erased   : java.util.ArrayList
retained : java.util.List<User>

Cặp {} ở cuối mới là thứ làm toàn bộ việc — nó tạo ra một anonymous subclass, và generic supertype của một subclass là một trong số ít thông tin generic mà class file còn giữ lại. Thiếu nó thì type argument biến mất.

Lambda, functional interface và Stream

Functional interface là interface có đúng một abstract method. Lambda là một instance của nó, viết ngay tại chỗ. java.util.function cung cấp sẵn bốn dạng bạn sẽ gặp liên tục:

InterfaceMethodÝ nghĩaLambda
Function<T,R>R apply(T)nhận một, trả về một cái khács -> s.length()
Supplier<T>T get()không nhận gì, sinh ra một giá trị() -> new Order(...)
Consumer<T>void accept(T)nhận một, không trả về gìo -> System.out.println(o)
Predicate<T>boolean test(T)nhận một, trả lời có hoặc khôngo -> o.total() > 100
Java
Function<String, Integer> length = s -> s.length();
Function<String, Integer> lengthRef = String::length;
Supplier<Order> fallback = () -> new Order("SKU-0", 0, "NONE");
Consumer<String> log = System.out::println;
Predicate<Order> paid = o -> o.status().equals("PAID");
 
System.out.println("Function  : " + length.apply("Spring") + " / " + lengthRef.apply("Spring"));
System.out.println("Supplier  : " + fallback.get());
System.out.println("Predicate : " + paid.test(new Order("SKU-1", 120, "PAID")));
log.accept("Consumer  : written by System.out::println");
Text
Function  : 6 / 6
Supplier  : Order[sku=SKU-0, total=0, status=NONE]
Predicate : true
Consumer  : written by System.out::println

String::lengthSystem.out::printlnmethod reference — cách viết tắt cho một lambda chỉ làm mỗi việc gọi một method. Có bốn dạng:

DạngVí dụLambda tương đương
static methodInteger::parseInts -> Integer.parseInt(s)
method của một object cụ thểSystem.out::printlnx -> System.out.println(x)
method của chính parameterString::lengths -> s.length()
constructorArrayList::new() -> new ArrayList<>()

Interface tự viết cũng chạy y như vậy; @FunctionalInterface không bắt buộc nhưng khiến compiler kiểm tra giúp quy tắc một method duy nhất:

Java
@FunctionalInterface
interface Discount {
    int apply(int amount);
}
 
Discount flat = amount -> amount - 10;
System.out.println("Custom FI : " + flat.apply(100));
Text
Custom FI : 90

Spring nhận lambda theo đúng hai cách đó. Một là nơi sinh ra giá trị hoặc exceptionorElseThrow(() -> new UserNotFoundException(id)) chỉ tạo exception khi thật sự cần tới. Hai là callback để config, khi một method trao cho bạn một builder và bạn chỉnh nó: cfg -> cfg.something(...). Cả hai đều là functional interface bình thường; không có gì riêng của Spring ở đây cả.

Một stream pipeline, từng stage một

Stream là một pipeline các thao tác chạy trên một nguồn. Các intermediate operation (filter, map, sorted) trả về một stream khác và tự nó không làm gì; một terminal operation (toList, sum, forEach) mới là thứ khiến toàn bộ chạy.

Java
List<Order> orders = List.of(
        new Order("SKU-1", 120, "PAID"),
        new Order("SKU-2", 40, "PAID"),
        new Order("SKU-3", 300, "CANCELLED"),
        new Order("SKU-4", 250, "PAID"));
 
System.out.println("-- pipeline built, nothing has run yet --");
var pipeline = orders.stream()
        .peek(o -> System.out.println("source  -> " + o.sku()))
        .filter(o -> o.status().equals("PAID"))
        .peek(o -> System.out.println("  paid  -> " + o.sku()))
        .filter(o -> o.total() >= 100)
        .peek(o -> System.out.println("  >=100 -> " + o.sku()))
        .map(Order::sku);
 
System.out.println("-- terminal operation starts here --");
List<String> result = pipeline.toList();
System.out.println("result = " + result);
Text
-- pipeline built, nothing has run yet --
-- terminal operation starts here --
source  -> SKU-1
  paid  -> SKU-1
  >=100 -> SKU-1
source  -> SKU-2
  paid  -> SKU-2
source  -> SKU-3
source  -> SKU-4
  paid  -> SKU-4
  >=100 -> SKU-4
result = [SKU-1, SKU-4]

Bốn order đi qua filter, filter và map, cho thấy cái nào sống sót ở mỗi stage và thứ tự xen kẽ mà các stage thật sự chạy

Hai điều trong đoạn trace đó đáng giá hơn cả cú pháp. Không có gì được in ra giữa "pipeline built" và "terminal operation starts here" — các intermediate operation chưa hề chạy. Và thứ tự không phải bốn lượt quét qua list: SKU-1 đi hết tới cuối trước khi SKU-2 được đọc, còn SKU-3 dừng ngay ở filter đầu tiên nên filter thứ hai không bao giờ nhìn thấy nó.

mapToInt kèm sum là pipeline còn lại bạn sẽ viết mỗi tuần:

Java
int revenue = orders.stream()
        .filter(o -> o.status().equals("PAID"))
        .mapToInt(Order::total)
        .sum();
Text
revenue = 410

Trong một ứng dụng Spring, việc này gần như luôn là cùng một bài toán: lấy List<UserEntity> mà repository trả về và tạo ra List<UserResponse> cho phía gọi.

Optional: giá trị trả về của findById

Optional<T> là một cái hộp chứa hoặc một giá trị, hoặc không gì cả. Nó tồn tại để "không có kết quả" được thể hiện ngay trong type trả về thay vì là một null mà caller quên kiểm tra. Spring Data dùng nó đúng vào việc đó: Optional<T> findById(ID).

MethodTrả vềDùng khi
isPresent() / isEmpty()booleanbạn chỉ cần câu trả lời có hay không
get()T, hoặc throwgần như không bao giờ — xem bên dưới
orElse(other)Tbạn có sẵn một giá trị thay thế rẻ tiền
orElseGet(supplier)Tgiá trị thay thế tốn công tạo ra
orElseThrow(supplier)T, hoặc throw exception của bạncaller buộc phải hỏng nếu không có
map(fn)Optional<R>bạn chỉ muốn một field bên trong giá trị
filter(pred)Optional<T>rỗng trừ khi giá trị còn thoả thêm điều kiện
ifPresent(consumer)voidchỉ làm gì đó khi có giá trị
Java
static Optional<User> findById(Long id) {
    return Optional.ofNullable(ROWS.get(id));
}
 
User user = findById(1L).orElseThrow(() -> new NoSuchElementException("user 1 not found"));
String email = findById(99L).map(User::email).orElse("(none)");
findById(1L).ifPresent(u -> System.out.println("ifPresent       : " + u.email()));
findById(99L).ifPresent(u -> System.out.println("never printed"));
Text
present : Optional[User[id=1, name=An, email=an@example.com]]
missing : Optional.empty
orElseThrow ok  : An
map + orElse    : (none)
ifPresent       : an@example.com
filter          : Optional.empty

orElseThrow kèm lambda là con ngựa kéo chính ở tầng service: nó mở hộp lấy giá trị, hoặc ném ra đúng exception mà tầng HTTP sẽ dịch lại.

Java
findById(99L).orElseThrow(() -> new IllegalStateException("user 99 not found"));
Text
orElseThrow     : java.lang.IllegalStateException: user 99 not found

Ba cách dùng sai Optional

Gọi get() mà không kiểm tra. Đây là NullPointerException với vài bước lòng vòng thêm:

Java
findById(99L).get();
Text
get() on empty  : java.util.NoSuchElementException: No value present

Dùng isPresent() rồi get(). Nó compile được và chạy được, nhưng đó chính là cái null check mà bạn đang muốn thoát khỏi, giờ dài thành ba dòng. map, orElseorElseThrow nói cùng một điều chỉ trong một dòng.

Đặt Optional vào field hoặc parameter. Nó được thiết kế để làm type trả về. Làm field thì tốn thêm một object cho mỗi instance và phá vỡ serialization hoàn toàn — Optional không implement Serializable:

Java
new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(Optional.of("x"));
Text
as a field      : NotSerializableException: java.util.Optional

Với parameter thì một overload hoặc một argument nullable bình thường rõ ràng hơn. Trả về Optional, còn lưu và truyền thì dùng giá trị bên trong.

Record: hình dạng DTO mà Spring bind JSON vào

Record là một vật chứa dữ liệu immutable. Một dòng khai báo ra các field, một canonical constructor, một accessor cho mỗi component, cùng equals, hashCodetoString:

Java
record CreateUserRequest(String name, String email, int age) {
    CreateUserRequest {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name must not be blank");
        }
        name = name.trim();
    }
}

Phần thân ở đó là một compact constructor — không có danh sách parameter, không có lệnh gán. Nó chạy trước khi các field được gán, nên vừa validate được vừa chuẩn hoá được bằng cách gán lại parameter, đúng như dòng name.trim().

Text
toString : CreateUserRequest[name=An, email=an@example.com, age=30]
accessor : An / an@example.com
equals   : true
hashCode : true
compact  : IllegalArgumentException: name must not be blank

Để ý tên accessor: req.name(), không phải req.getName(). Record không theo quy ước JavaBean.

Tính immutable do compiler ép, không phải do quy ước. Các field là final, và bản thân class cũng final:

Java
record Point(int x, int y) {
    void moveRight() {
        this.x = x + 1;
    }
}
Text
RecordFinal.java:3: error: cannot assign a value to final variable x
        this.x = x + 1;
            ^
Text
RecordInherit.java:3: error: cannot inherit from final Point3
class Sub extends Point3 {
                  ^

Đó đúng là thứ một DTO cần: một giá trị đi vào từ request, được validate một lần rồi không đổi nữa. Jackson — bản 3.1.5, đúng bản Boot 4.1.1 quản lý — bind thẳng vào record, dùng canonical constructor để deserialize:

Java
ObjectMapper mapper = JsonMapper.builder().build();
String json = mapper.writeValueAsString(req);
 
CreateUserRequest back = mapper.readValue(
        "{\"name\":\"Binh\",\"email\":\"binh@example.com\",\"age\":25}",
        CreateUserRequest.class);
Text
serialize   : {"name":"An","email":"an@example.com","age":30}
deserialize : CreateUserRequest[name=Binh, email=binh@example.com, age=25]
round trip  : true

Vòng round trip đó chính là lý do record chiếm chỗ của DTO. Cùng một class vừa mô tả request body vừa là class mà JSON parser dựng được, không cần setter và không có gì phải giữ đồng bộ.

recordclass có getter/setter
Số dòng cho ba field1khoảng 40, hoặc một annotation Lombok
Sửa được sau khi tạokhông
equals / hashCodesinh ra từ mọi componenttự viết hoặc nhờ công cụ sinh
Validationcompact constructorconstructor hoặc setter
Làm @Entity của JPA được khôngkhông

Dòng cuối là dòng cần nhớ. JPA đòi một constructor không có parameter và các field không final, nên record không thể làm entity. Dùng record cho các DTO ở rìa ứng dụng, còn entity ở giữa thì vẫn là một class bình thường.

Annotation và reflection: vì sao Spring không phải phép thuật

Đây là phần quan trọng nhất. Annotation là metadata trơ. Nó gắn thông tin vào một khai báo và tự nó không thay đổi gì hết. Phải có thứ khác đọc nó rồi hành động — và một khi bạn đã tự viết phần đọc đó, @Component với @GetMapping hết bí ẩn.

Khai báo một annotation

Một annotation type được khai báo bằng @interface, và hai annotation gắn trên chính nó quyết định nó được đặt ở đâu và sống được bao lâu:

Java
import java.lang.annotation.*;
 
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Handler {
    String value();
}
 
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Route {
    String path();
    String method() default "GET";
}

value() là trường hợp đặc biệt: annotation chỉ có duy nhất một element tên value thì viết được @Handler("/users") thay vì @Handler(value = "/users"). Element nào có default thì được phép bỏ qua.

Giờ đặt chúng lên một class. Hai annotation nữa được gắn vào có chủ ý, với retention yếu hơn, để chứng minh một điều ngay sau đây:

Java
@Handler("/users")
@DevNote("retention SOURCE - discarded by javac")
@BuildOnly("retention CLASS - in the class file, not loaded at runtime")
public class UserController {
 
    @Route(path = "/{id}")
    public String findOne(String id) {
        return "user " + id;
    }
 
    @Route(path = "", method = "POST")
    public String create(String body) {
        return "created " + body;
    }
 
    public String notARoute() {
        return "never mapped";
    }
}

Gọi thẳng class đó chứng minh rằng annotation không làm gì cả:

Text
-- the annotation on its own changes nothing --
user 7

Không route nào được map, không có dispatch, không có gì bị chặn lại. Đó là một lời gọi method bình thường trên một object bình thường.

Đọc lại nó bằng reflection

Reflection là API để soi một class lúc runtime. Mười lăm dòng biến đống metadata ở trên thành một bảng route chạy được:

Java
Class<?> type = UserController.class;
 
if (!type.isAnnotationPresent(Handler.class)) {
    System.out.println(type.getSimpleName() + " has no @Handler, skipped");
    return;
}
 
String base = type.getAnnotation(Handler.class).value();
Object instance = type.getDeclaredConstructor().newInstance();
 
Map<String, Method> routes = new LinkedHashMap<>();
Method[] methods = type.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
for (Method m : methods) {
    Route route = m.getAnnotation(Route.class);
    if (route == null) continue;
    routes.put(route.method() + " " + base + route.path(), m);
}
 
routes.forEach((key, m) -> System.out.println("mapped " + key + " -> " + m.getName() + "()"));
System.out.println("GET /users/{id} => " + routes.get("GET /users/{id}").invoke(instance, "42"));
System.out.println("POST /users     => " + routes.get("POST /users").invoke(instance, "{\"name\":\"An\"}"));
Text
-- route table built by reading annotations --
mapped POST /users -> create()
mapped GET /users/{id} -> findOne()
-- dispatch --
GET /users/{id} => user 42
POST /users     => created {"name":"An"}

Toàn bộ mánh nằm ở đó. getAnnotation đọc metadata, getDeclaredMethods liệt kê ứng viên, newInstance dựng object và invoke gọi một method được chọn lúc runtime chứ không phải lúc compile. notARoute() không bao giờ được map vì nó không mang @Route. Phóng to chuyện này lên — quét cả một package thay vì một class, cache lại bảng, xử lý parameter và giá trị trả về — là bạn có bộ khung của việc một web framework làm lúc khởi động.

RetentionPolicy thật sự quyết định điều gì

@Retention là một bộ lọc có hai cửa, và nó là nguyên nhân phổ biến nhất khiến một annotation tự viết "chẳng làm gì cả".

PolicyTrong sourceTrong class fileReflection nhìn thấy
SOURCEkhôngkhông
CLASS (mặc định)không
RUNTIME

Annotation đi từ source qua javac vào class file rồi tới reflection, SOURCE bị bỏ lúc compile và CLASS bị bỏ trước runtime

UserController mang ba annotation với ba policy khác nhau. Lúc runtime, chỉ một cái còn tồn tại:

Java
System.out.println(Arrays.toString(UserController.class.getAnnotations()));
Text
[@Handler("/users")]

Nhưng @BuildOnly chưa biến mất — nó vẫn nằm trong class file, chỉ là ở một attribute mà class loader không phơi ra. javap -v cho thấy cả hai attribute nằm cạnh nhau:

Text
RuntimeVisibleAnnotations:
  0: #31(#32=s#33)
    Handler(
      value="/users"
    )
RuntimeInvisibleAnnotations:
  0: #35(#32=s#36)
    BuildOnly(
      value="retention CLASS - in the class file, not loaded at runtime"
    )

@DevNote không có trong attribute nào cả. Tìm trong class file các descriptor của annotation type thì chỉ thấy ba trên bốn:

Bash
strings UserController.class | grep -o -E 'L(Handler|Route|DevNote|BuildOnly);' | sort -u
Text
LBuildOnly;
LHandler;
LRoute;

javac đã xoá nó. Đó đúng là mục đích của SOURCE: @Override@SuppressWarnings là những kiểm tra lúc compile, sau đó không còn gì để nói, nên chúng không để lại dấu vết.

⚠️ Retention mặc định là CLASS, không phải RUNTIME. Một annotation bạn định đọc bằng reflection mà khai báo thiếu @Retention thì vẫn compile ngon, vẫn build ra bình thường, và getAnnotations() lặng lẽ không trả về gì cho nó.

Annotation của chính Spring cũng được dựng y như vậy

Không có gì ở trên là đặc quyền của một ví dụ đồ chơi. Soi bằng reflection vào chính các annotation thật của Spring Framework 7.0.9 cho thấy đúng hai quyết định đó — retention RUNTIME, và ghép lại bằng meta-annotation:

Java
for (Class<?> a : new Class<?>[] { Component.class, Service.class, RestController.class, GetMapping.class }) {
    Retention r = a.getAnnotation(Retention.class);
    // ... collect a.getAnnotations(), skipping the java.lang.annotation.* ones
    System.out.printf("@%-15s retention=%-8s meta-annotated with: %s%n", a.getSimpleName(), r.value(), meta);
}
Text
@Component       retention=RUNTIME  meta-annotated with: @Indexed
@Service         retention=RUNTIME  meta-annotated with: @Component
@RestController  retention=RUNTIME  meta-annotated with: @Controller, @ResponseBody
@GetMapping      retention=RUNTIME  meta-annotated with: @RequestMapping

Cái nào cũng RUNTIME, vì Spring đọc chúng bằng reflection lúc khởi động — y hệt cách bảng route ở trên làm. Và một annotation có thể mang annotation của riêng nó: @Service chính là một @Component, @RestController chính là @Controller cộng @ResponseBody. Chính cách ghép đó khiến Spring coi một class gắn @Service là component mà không cần container phải có trường hợp riêng nào cho @Service.

Bạn kiểm chứng được cùng quan hệ đó trên type của mình:

Java
System.out.println("direct @Stereotype? " + type.isAnnotationPresent(Stereotype.class));
for (Annotation a : type.getAnnotations()) {
    System.out.println(a.annotationType().getSimpleName() + " meta-annotated @Stereotype? "
            + a.annotationType().isAnnotationPresent(Stereotype.class));
}
Text
direct @Stereotype? false
Handler meta-annotated @Stereotype? true

UserController không được gắn @Stereotype, nhưng annotation mà nó được gắn thì có mang — nên một bộ scan chịu nhìn lên một tầng sẽ tìm thấy. Chính ý tưởng duy nhất đó, áp dụng lúc khởi động lên mọi class trên classpath, là phần lớn những gì Spring Boot làm trước khi code của bạn chạy.

Bạn cần làm được gì trước khi sang bài 2

Bạn cần làm đượcDấu hiệu đạt
viết một interface với hai implementation, và một caller chỉ nhắc tên interfacecaller không có new cho cả hai implementation
chọn được giữa List, SetMap và biết cái nào immutableList.of(...) throw khi gọi add
đọc được Repository<T, ID> và nói ra T với ID là gì trong JpaRepository<User, Long>findById trả về Optional<User>
viết lambda cho Function, Supplier, ConsumerPredicatevà dạng method reference của từng cái
dựng một stream pipeline và gọi tên terminal operation của nóthiếu nó thì không có gì chạy
mở một Optional mà không gọi get()map, orElse, orElseThrow
viết một record có compact constructorfield là final, accessor không có tiền tố get
khai báo một annotation RUNTIME rồi đọc lại bằng reflectiongetAnnotation rồi invoke

Nếu dòng cuối đã chắc, phần còn lại của series chỉ là config và bề mặt API chứ không còn là chuyện bí ẩn.

FAQ

Có bắt buộc phải dùng Java 21 không, hay Java 17 là đủ?

Spring Boot 4.1.1 yêu cầu tối thiểu Java 17 — class file của nó là major version 61 — nên bản 17 chạy được mọi thứ trong series. Ở đây dùng Java 21 vì đó là bản LTS hiện tại và vì record, varStream.toList() đều đã ổn định trong nó. Không phần nào của series phụ thuộc vào tính năng chỉ có ở Java 21.

Vì sao annotation tự viết của tôi không có tác dụng gì?

Hai nguyên nhân, theo thứ tự này. Hoặc retention không phải RUNTIME nên reflection không nhìn thấy — mặc định là CLASS, và CLASS vô hình lúc runtime. Hoặc không có gì đọc nó: annotation tự thân không bao giờ đổi hành vi, phải có thứ gọi getAnnotation rồi xử lý kết quả. Trong Spring, thứ đọc đó là container lúc khởi động, và nó chỉ nhìn những class nó scan tới.

Reflection có chậm tới mức phải lo không?

Với những việc mô tả ở đây thì không. Spring trả chi phí reflection một lần lúc khởi động khi dựng metadata, chứ không phải mỗi request. Reflection trong một vòng lặp nóng của code bạn viết lại là chuyện khác — hãy cache object Method thay vì tra lại mỗi lần.

Entity nên dùng record hay class?

Class. JPA đòi một constructor không có parameter và các field không final, mà record không có cả hai. Record hợp với DTO ở rìa — request body và response — còn entity ở giữa vẫn là class bình thường.

Optional.orElse khác Optional.orElseGet ở chỗ nào?

orElse nhận một giá trị, nên argument được tính bất kể có cần hay không. orElseGet nhận một Supplier, nên giá trị thay thế chỉ được dựng khi Optional rỗng. Dùng orElse cho hằng số, và orElseGet mỗi khi việc tạo ra giá trị thay thế tốn kém — một lần gọi database, một object graph, một list mới.

Vì sao Spring cần ParameterizedTypeReference khi đã có generics?

Vì generics bị erase. Lúc runtime một List<User> chỉ là một List, nên một client phải dựng object từ response body sẽ không có gì để dựng. ParameterizedTypeReference được tạo dưới dạng anonymous subclass, mà generic supertype của một subclass thì được giữ trong class file, nên getType() vẫn báo được java.util.List<User>.

Kết luận

Không có gì trong danh sách này là Spring cả. Interface cho framework một chỗ để thay implementation vào, generics cho phép một interface repository phục vụ mọi entity, Optional biến "không tìm thấy" thành một type trả về, record rút DTO xuống một dòng, còn annotation cộng reflection là cơ chế để một framework đọc code của bạn rồi hành động. Bảng route dựng ở trên là bản phác năm mươi dòng của thứ Spring làm trên cả một classpath lúc khởi động.

Bài tiếp theo nói về chính cái framework đó: Spring Framework và Spring Boot khác nhau thế nào, auto-configuration thật sự làm gì, một starter chứa những gì, và embedded server ở đâu ra.

Bài viết liên quan

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

JSON trong Spring Boot 4.1.1 với Jackson 3.1.5, kiểm chứng trên project thật: JacksonJsonHttpMessageConverter và bean jacksonJsonMapper, package tools.jackson, JsonMapper immutable và exception unchecked, đo các giá trị mặc định của Jackson 3 so với use-jackson2-defaults, @JsonProperty, @JsonIgnore, @JsonInclude, @JsonFormat, BigDecimal, enum và Optional, record, @JsonAlias và @JsonCreator, property spring.jackson và JsonMapperBuilderCustomizer, vì sao DTO tốt hơn để lộ entity, map bằng tay và MapStruct 1.6.3 với Gradle và Maven.

[Spring Boot Basics] Server-side rendering với Thymeleaf trong Spring Boot: template, form và validation

Server-side rendering với Thymeleaf 3.1.5 trong Spring Boot 4.1.1, kiểm chứng trên application đang chạy: khi nào SSR hợp hơn JSON API, view name trở thành classpath:/templates/products/list.html ra sao, năm loại standard expression, th:text và th:utext với XSS, th:each cùng #numbers và #temporals, form tạo mới có validation với th:field và th:errors, vị trí bắt buộc của BindingResult, Post/Redirect/Get với flash attribute, fragment, CSS tĩnh, spring.thymeleaf.cache thực sự thay đổi gì, và trang lỗi HTML.

[Spring Boot Basics] @ConfigurationProperties trong Spring Boot: cấu hình type-safe kết hợp validation

Cấu hình type-safe trong Spring Boot 4.1.1 với @ConfigurationProperties, kiểm chứng bằng các lần chạy thật: bind vào record không cần @ConstructorBinding, JavaBean binding và @DefaultValue, ba cách đăng ký properties class, object lồng nhau, list, map, enum, chuyển đổi Duration và DataSize, relaxed binding và cách đặt tên environment variable, lỗi khởi động fail-fast với @Validated, metadata từ configuration processor, và bảng so sánh với @Value.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator 9.1.3, kiểm chứng bằng các lần chạy thật: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.