รีเซต Dev DB ให้อยู่สถานะเดียวกันทุกครั้งด้วย DevPortSeedInitializer — CommandLineRunner ของ Spring Boot ในทางปฏิบัติ
สิ่งที่คุณจะได้เรียนรู้
- รูปแบบการ insert ข้อมูลทดสอบตายตัวเมื่อ startup ด้วย
CommandLineRunner+@Profile("dev")ของ Spring Boot - วิธี
upsert(findById + save) ที่ ไม่ insert ซ้ำเมื่อ restart - algorithm กระจายพอร์ตหลายตัวเป็นวงกลมรอบสถานี
- การออกแบบที่ จงใจผสมข้อมูลผิดปกติ (พอร์ตเต็ม รถเสีย) เพื่อตรวจสอบ
กลุ่มเป้าหมาย
- คนที่เบื่อกับการ
truncate + insertทุกครั้งบน dev DB - ผู้ที่ต้องการข้อมูลตายตัว “พอร์ตเต็ม” / “รถเสีย” เพื่อตรวจ E2E
- ผู้ที่อยากใช้กลไก seed ใน Spring Boot stdlib อย่างเดียว (เบากว่า Flyway / Liquibase)
สภาพแวดล้อม
| รายการ | เวอร์ชัน |
|---|---|
| Java | 21 |
| Spring Boot | 3.4.5 |
| PostgreSQL | 17.2 |
| Spring Data JPA | 3.x |
บทความในชุด (อยู่ระหว่างการพัฒนา) — บทความนี้เป็นส่วนหนึ่งของ สร้าง E-Scooter Sharing ระดับท้องถนนตั้งแต่ศูนย์เพื่อทำความเข้าใจ — ชุดบันทึก Design, Implementation, และ Operations โปรเจกต์ยังดำเนินอยู่และมีการเพิ่มบทความและบันทึก design ใหม่อย่างต่อเนื่อง แรงจูงใจของชุด, technology stack, อภิธานศัพท์ screen ID, และดัชนีบทความถูกรวบรวมไว้ที่ลิงก์ด้านบน
บทนำ
ใน phase PoC กับ PostgreSQL คุณจะเจอปัญหานี้ตลอด:
- สร้าง DB ใหม่ ข้อมูลหลักหาย หน้าจอเปล่า
- แชร์ environment dev กับเพื่อนผ่าน “ส่ง SQL ใน Slack ให้นะ”
- สร้างข้อมูลผิดปกติอย่าง “พอร์ตเต็ม” / “อยู่ระหว่างซ่อม” ด้วยมือทุกครั้ง
เราแก้ด้วย insert ข้อมูลใน Java code ตอน app startup

หมุดเขียวคือ “พอร์ตที่มีรถใช้ได้” หมุดเทาคือ “ไม่มีรถใช้ได้หรือพอร์ตเต็ม” ความหลากหลายนี้ถูกสร้างขึ้นโดย seed code อย่างจงใจ — ธีมของบทความนี้
โครงสร้างโดยรวม
@Component
@Profile("dev")
@RequiredArgsConstructor
public class DevPortSeedInitializer implements CommandLineRunner {
private final PortRepository portRepository;
private final ScooterRepository scooterRepository;
@Override
public void run(String... args) {
seedStation("TOKYO", "บริเวณสถานี Tokyo", 35.681236, 139.767125);
seedStation("IKEBUKURO", "บริเวณสถานี Ikebukuro", 35.728926, 139.710380);
seedStation("OTSUKA", "บริเวณสถานี Otsuka", 35.731401, 139.728662);
seedStation("SHINJUKU", "บริเวณสถานี Shinjuku", 35.690921, 139.700258);
}
// ...
}
จุดสำคัญ:
@Profile("dev")หมายความว่า bean ไม่ถูกสร้างใน prod ไม่มีการ insert โดยบังเอิญใน productionCommandLineRunner.runถูกเรียกครั้งเดียวตอนท้ายของ startup ของ Spring Boot- แบ่ง method ตามสถานี เพื่อรวมการกระจายภูมิศาสตร์และการคุมจำนวนไว้ที่จุดเดียว
Upsert Logic เพื่อความปลอดภัยตอน Restart
save ตรง ๆ จะทำให้ฟ้อง duplicate-key ทุกครั้งที่ restart แทนที่จะใช้แบบนั้น ใช้ findById เช็ค แล้ว build ใหม่หรือ update ที่มีอยู่:
private void seedStation(String code, String stationLabel, double centerLat, double centerLng) {
for (int i = 1; i <= 20; i++) {
String index = String.format("%02d", i);
String portId = "PORT-" + code + "-" + index;
String portName = stationLabel + " พอร์ต " + index;
LatLng latLng = distributedPoint(centerLat, centerLng, i);
Port port = portRepository.findById(portId)
.orElseGet(() -> Port.builder().portId(portId).build());
port.setName(portName);
port.setLatitude(BigDecimal.valueOf(latLng.lat));
port.setLongitude(BigDecimal.valueOf(latLng.lng));
port.setTotalCapacity(12);
portRepository.save(port);
// upsert แบบเดียวกันสำหรับรถ
upsertScooter(port, "SC-" + code + "-" + index + "-A", "AVAILABLE");
upsertScooter(port, "SC-" + code + "-" + index + "-B", "AVAILABLE");
upsertScooter(port, "SC-" + code + "-" + index + "-C", "MAINTENANCE");
}
}
private void upsertScooter(Port port, String scooterId, String status) {
Scooter scooter = scooterRepository.findById(scooterId)
.orElseGet(() -> Scooter.builder().scooterId(scooterId).build());
scooter.setStatus(status);
scooter.setBatteryLevel("AVAILABLE".equals(status) ? 85 : 45);
scooter.setCurrentLatitude(port.getLatitude());
scooter.setCurrentLongitude(port.getLongitude());
scooter.setCurrentPort(port);
scooterRepository.save(scooter);
}
ผลลัพธ์:
- start ครั้งแรก: INSERT ทุกแถว
- start ถัดไป: UPDATE แถวที่มีอยู่ (lat/lng หรือ status ตามการเปลี่ยน)
- สถานะที่ผู้ใช้แก้ระหว่าง runtime (เช่น IN_USE) จะกลับเป็น AVAILABLE/MAINTENANCE ใน restart ครั้งถัดไป
ข้อสุดท้ายให้ผล “การ start dev กลับสู่สถานะเริ่มต้นที่รู้จักเสมอ” แม้สถานะพังระหว่างการตรวจสอบ การ restart ก็รีเซตได้ — ความสบายใจจริง
การกระจายแบบวงกลมรอบสถานี
วาง 20 พอร์ตที่พิกัดเดียวกันจะทับกัน เลยกระจายโดยระยะทาง:
private LatLng distributedPoint(double centerLat, double centerLng, int index) {
int ring = index <= 8 ? 0 : (index <= 14 ? 1 : 2);
double radiusMeters = ring == 0 ? 180 : (ring == 1 ? 320 : 520);
double angleDeg = (index * 31) % 360;
double latDelta = (radiusMeters / 111_320.0) * Math.cos(Math.toRadians(angleDeg));
double lngScale = Math.max(Math.cos(Math.toRadians(centerLat)), 0.1);
double lngDelta = (radiusMeters / (111_320.0 * lngScale)) * Math.sin(Math.toRadians(angleDeg));
return new LatLng(centerLat + latDelta, centerLng + lngDelta);
}
Algorithm:
- index 1〜8 → วงใน (รัศมี 180m)
- index 9〜14 → วงกลาง (รัศมี 320m)
- index 15〜20 → วงนอก (รัศมี 520m)
- มุม:
index * 31 % 360(31 coprime กับ 360 ดัชนีที่ติดกันจึงไม่กระจุก)
111_320.0 คือเมตรต่อองศาของละติจูดใกล้เส้นศูนย์สูตร ลองจิจูดแปรตามละติจูด เลยแก้ด้วย cos(centerLat)
ผลคือพอร์ตเรียงเป็นวงกลมซ้อนรอบแต่ละสถานี เห็นทั้งหมดบนแผนที่
จงใจผสมข้อมูลผิดปกติ
ระหว่างการตรวจสอบ บางครั้งอยากได้ “พอร์ตเต็ม” หรือ “พอร์ตซ่อมทั้งหมด” seed เข้าไปจงใจ:
for (int i = 1; i <= 20; i++) {
// i % 7 == 0 (i=7, 14 ต่อสถานี) กลายเป็น "พอร์ตเต็ม" — totalCapacity เท่ากับจำนวนรถ
// ทำให้ available_empty_slots = 0
boolean isFullPort = i % 7 == 0;
String nameSuffix = isFullPort ? " (เต็ม)" : "";
int totalCapacity = isFullPort ? 3 : 12;
Port port = portRepository.findById(portId)
.orElseGet(() -> Port.builder().portId(portId).build());
port.setName(portName + nameSuffix);
port.setTotalCapacity(totalCapacity);
portRepository.save(port);
int availableCount = i % 5 == 0 ? 0 : 2;
upsertScooter(port, "SC-" + code + "-" + index + "-A",
availableCount >= 1 ? "AVAILABLE" : "MAINTENANCE");
upsertScooter(port, "SC-" + code + "-" + index + "-B",
availableCount >= 2 ? "AVAILABLE" : "MAINTENANCE");
upsertScooter(port, "SC-" + code + "-" + index + "-C", "MAINTENANCE");
}
คุณสมบัติที่เข้ารหัสใน i:
i % 7 == 0(i=7, 14) → พอร์ตเต็ม 2 ต่อสถานี × 4 สถานี = 8 ทั้งหมดi % 5 == 0(i=5, 10, 15, 20) → พอร์ตที่ไม่มี AVAILABLE 4 ต่อสถานี × 4 = 16 ทั้งหมด- นอกนั้น → AVAILABLE 2 + MAINTENANCE 1
เปิดแผนที่จะเห็นหมุดเขียว (ปกติ) กับหมุดเทา (เต็มหรือไม่มี available) ปนกัน logic “การแบ่งสีหมุด” / “การปิดใช้งานปุ่ม” ฝั่ง client ถูกตรวจตั้งแต่ moment ของ startup

ตัวต่อท้าย “(เต็ม)” ในชื่อพอร์ตก็ถูกสร้างโดย seed code เป็น isFullPort ? " (เต็ม)" : "" ดังนั้นเมื่อดูก็รู้ได้ทันทีว่า “นี่คือสิ่งที่ seed ตั้งใจไว้พอดี” เป็น fixture การตรวจสอบที่ตายตัวและทำซ้ำได้ สำหรับเช็คว่า “การปิดใช้งานปุ่มแสดงตามที่คาดไว้”
ทำไมไม่ใช่ Flyway / Liquibase
เครื่องมือ Migration (Flyway / Liquibase) seed ได้ แต่สำหรับข้อมูลทดสอบ dev Java มีเหตุผล:
- loop หลายร้อยบรรทัดและคำนวณพิกัดใน SQL อ่านยาก
- ใส่ logic การสร้างพิกัด ใน SQL ทำให้ใช้ซ้ำใน test ไม่ได้
@Profile("dev")จำกัดให้อยู่ใน dev ทั้งหมด (Flyway มักคาดให้รันใน prod ด้วย)
การแบ่งงานที่เป็นธรรมชาติ: schema management ด้วย Flyway / Liquibase, ข้อมูลทดสอบด้วย CommandLineRunner
บทเรียน
- dev seed เรียบง่ายและแข็งแกร่งเป็น
@Profile("dev")+CommandLineRunner - upsert logic (findById → save) ให้ทนต่อการ restart — ความเสียหายของสถานะ runtime ไม่น่ากลัวอีก
- สถานี / พิกัด / จำนวน / ผิดปกติ เป็น declarative ใน code review ได้
- branch แบบ modulo อย่าง
i % N == 0seed ผิดปกติแบบจงใจ ทำให้การตรวจประจำง่ายขึ้น
ใน phase PoC ตอนแรก “รันและตรวจสอบ design” คือความสำคัญ “ทุกครั้งที่เปิด DB จะเห็นภาพเดียวกัน” ทำให้ประสบการณ์ dev มั่นคงกว่าที่คุณคาดไว้