Tech Blog

รีเซต Dev DB ให้อยู่สถานะเดียวกันทุกครั้งด้วย DevPortSeedInitializer — CommandLineRunner ของ Spring Boot ในทางปฏิบัติ

by Tech Writer
Spring Boot Java PostgreSQL CommandLineRunner Test Data dev profile

สิ่งที่คุณจะได้เรียนรู้

  • รูปแบบการ insert ข้อมูลทดสอบตายตัวเมื่อ startup ด้วย CommandLineRunner + @Profile("dev") ของ Spring Boot
  • วิธี upsert (findById + save) ที่ ไม่ insert ซ้ำเมื่อ restart
  • algorithm กระจายพอร์ตหลายตัวเป็นวงกลมรอบสถานี
  • การออกแบบที่ จงใจผสมข้อมูลผิดปกติ (พอร์ตเต็ม รถเสีย) เพื่อตรวจสอบ

กลุ่มเป้าหมาย

  • คนที่เบื่อกับการ truncate + insert ทุกครั้งบน dev DB
  • ผู้ที่ต้องการข้อมูลตายตัว “พอร์ตเต็ม” / “รถเสีย” เพื่อตรวจ E2E
  • ผู้ที่อยากใช้กลไก seed ใน Spring Boot stdlib อย่างเดียว (เบากว่า Flyway / Liquibase)

สภาพแวดล้อม

รายการเวอร์ชัน
Java21
Spring Boot3.4.5
PostgreSQL17.2
Spring Data JPA3.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

หน้าแผนที่หลักทันทีหลัง launch — 4 สถานี × 20 พอร์ตวางไว้แล้ว

หมุดเขียวคือ “พอร์ตที่มีรถใช้ได้” หมุดเทาคือ “ไม่มีรถใช้ได้หรือพอร์ตเต็ม” ความหลากหลายนี้ถูกสร้างขึ้นโดย 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 โดยบังเอิญใน production
  • CommandLineRunner.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

PORT-OTSUKA-14 (เต็ม) — ผลของเงื่อนไข seed i=14 ที่กำหนด totalCapacity=3

ตัวต่อท้าย “(เต็ม)” ในชื่อพอร์ตก็ถูกสร้างโดย 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 == 0 seed ผิดปกติแบบจงใจ ทำให้การตรวจประจำง่ายขึ้น

ใน phase PoC ตอนแรก “รันและตรวจสอบ design” คือความสำคัญ “ทุกครั้งที่เปิด DB จะเห็นภาพเดียวกัน” ทำให้ประสบการณ์ dev มั่นคงกว่าที่คุณคาดไว้

ส่งข้อความได้ตามสบาย

กรุณาส่งข้อความ หากมีคำปรึกษาด้านเทคนิค ความคิดเห็น หรือคำถาม