บันทึกการฝ่า CORS และ Proxy ใน Vue 3 + Vite + Spring Boot

Vue3 Spring Boot Vite CORS

บทนำ

เมื่อพัฒนาโดยแยก frontend เป็น Vue 3 + Vite (พอร์ต 5173) และ backend เป็น Spring Boot (พอร์ต 8082)
สิ่งแรกที่ต้องเจอแน่นอนคือ CORS error

Access to XMLHttpRequest at 'http://localhost:8082/api/films'
from origin 'http://localhost:5173' has been blocked by CORS policy

บทความนี้เป็นบันทึกการฝ่า error นี้ พร้อมวิธีแก้ที่ถูกต้องสำหรับแต่ละ environment


CORS คืออะไรกันแน่

CORS (Cross-Origin Resource Sharing) คือ security feature ของ browser

พูดสั้นๆ คือ “request จาก http://localhost:5173 ไปยัง http://localhost:8082 ถือเป็นคนละ origin ดังนั้น browser จึง block ไว้”

จุดสำคัญ: CORS คือข้อจำกัดที่ browser เป็นผู้บังคับใช้
การสื่อสารระหว่าง server (เครื่องมืออย่าง curl หรือ Postman) จะไม่เกิด CORS


วิธีแก้ตอนพัฒนา: devProxy ของ Vite

ตอนพัฒนา การใช้ ฟีเจอร์ proxy ของ Vite เป็นวิธีที่ง่ายที่สุด

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8082',
        changeOrigin: true,
        // ครั้งนี้ไม่จำเป็นต้อง rewrite (เพราะ backend ก็ใช้ prefix /api เหมือนกัน)
      }
    }
  }
})

ด้วย setting นี้:

  • browser ส่ง request ไปที่ http://localhost:5173/api/films
  • Vite server รับไว้และ forward ไปที่ http://localhost:8082/api/films
  • จากมุมมองของ browser นี่คือ “การสื่อสารไปยัง origin เดียวกัน (พอร์ต 5173)” จึงไม่เกิด CORS

การเรียก API ฝั่ง Frontend

// api/films.ts
export async function fetchFilms(): Promise<Film[]> {
  // สลับ base URL ตาม environment
  const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
  const res = await fetch(`${baseUrl}/api/films`)
  if (!res.ok) throw new Error('API error')
  return res.json()
}

ตอนพัฒนา ไม่ได้ตั้งค่า VITE_API_BASE_URL จึงกลายเป็น string ว่าง แล้ว /api/films จะไปถึง Spring Boot ผ่าน Vite proxy


วิธีแก้ตอน Production: การตั้งค่า CORS ของ Spring Boot

ใน production frontend และ backend อาจกลายเป็นคนละ domain (หรือคนละพอร์ต)

วิธีที่ 1: ตั้งค่า CORS แบบ Global

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Value("${app.cors.allowed-origins}")
    private String[] allowedOrigins;
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(allowedOrigins)
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);
    }
}
# application.yml
app:
  cors:
    allowed-origins: http://localhost:5173

# application-prod.yml
app:
  cors:
    allowed-origins: https://your-production-frontend.com

วิธีที่ 2: annotation @CrossOrigin (ต่อ controller)

@RestController
@CrossOrigin(origins = "${app.cors.allowed-origins}")
@RequestMapping("/api/films")
public class FilmController {
    // ...
}

จุดที่ติดขัด

① ลืม preflight request (OPTIONS)

ใน CORS ก่อน request จริง จะมีการตรวจสอบล่วงหน้าด้วย method OPTIONS ถามว่า “request นี้ได้รับอนุญาตหรือไม่”

ถ้าใช้ Spring Security อยู่ request OPTIONS อาจถูกบล็อกโดยระบบ authentication

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())  // ปิด CSRF สำหรับ REST API
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // อนุญาต OPTIONS ทั้งหมด
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

allowedOrigins("*") และ allowCredentials(true) อยู่ร่วมกันไม่ได้

Wildcard กับการส่งข้อมูล credential ใช้พร้อมกันไม่ได้

When allowCredentials is true, allowedOrigins cannot contain the special value "*"

ถ้าใช้ credential (เช่น session cookie) ให้ระบุ origin ที่เจาะจงแทน

③ ระวัง slash ท้าย URL ใน production

# แบบนี้อาจกลายเป็น NG
allowed-origins: https://example.com/

# ไม่มี slash ท้าย
allowed-origins: https://example.com

การสลับด้วย Environment Variable

# .env (ตอนพัฒนา)
VITE_API_BASE_URL=

# .env.production (ตอน build production)
VITE_API_BASE_URL=https://api.example.com

ใน production build เมื่อใส่ URL ของ API production ลงใน VITE_API_BASE_URL จะเรียก backend โดยตรงโดยไม่ต้องผ่าน proxy


สรุป

Environmentวิธีแก้
Development (localhost)ตั้งค่า server.proxy ของ Vite
Production (domain เดียวกัน)ส่งต่อ /api ผ่าน reverse proxy อย่าง nginx
Production (คนละ domain)ตั้งค่า CORS ของ Spring Boot + ระบุ allowed-origins เป็น production URL

CORS error คือ “security feature ของ browser” ดังนั้นสิ่งสำคัญคือ อนุญาตให้ถูกต้อง ไม่ใช่หลบเลี่ยง
การให้ Vite proxy จัดการตอนพัฒนา และให้ Spring Boot จัดการตอน production คือการแบ่งความรับผิดชอบที่ถูกต้อง


การใช้งานจริงในแอพนี้

vite.config.ts (โค้ดจริง)

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8082',  // พอร์ตของ Spring Boot
        changeOrigin: true
        // ไม่จำเป็นต้อง rewrite (path ถูก forward ตามเดิม)
      }
    }
  }
})

request ทุกตัวที่ขึ้นต้นด้วย /api จะถูก forward ไปที่ http://localhost:8082 changeOrigin: true จะเขียน header Origin ของ request ใหม่ให้ตรงกับ target

CorsConfig.java (โค้ดจริง)

// config/CorsConfig.java
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    /**
     * ลงทะเบียนกฎ CORS ภายใต้ path ของ API สำหรับหน้าจอพัฒนาบนเครื่อง local
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173")
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

จุดสำคัญ: เนื่องจากระบุ allowCredentials(true) ไว้ จึงใช้ allowedOrigins("*") ไม่ได้ จึงระบุ origin ที่เจาะจงคือ http://localhost:5173 แทน

เหตุผลที่ตั้งค่าทั้งสองในตอนพัฒนา: Vite proxy หลีกเลี่ยง CORS ด้วย flow “browser → Vite → Spring Boot” แต่การมี CORS setting ฝั่ง Spring Boot ด้วย ทำให้รองรับกรณีในอนาคตที่ frontend และ backend รันคนละ origin หรือการเข้าถึงโดยตรงเช่น Swagger UI ได้ด้วย


แผนที่บทความของ Series นี้

สร้างแอป DVD Rental สำหรับผู้ใช้ปลายทาง — โครงสร้าง Vue 3 + Spring Boot คู่กับแอปผู้ดูแลระบบ พร้อมแผนที่บทความ

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

ไม่ว่าจะเป็นการว่าจ้างงาน แนะนำโปรเจกต์ ความคิดเห็น หรือคำถาม ยินดีรับทั้งหมด ฉันหวังเป็นอย่างยิ่งว่าจะได้เชื่อมต่อกับผู้ที่มีอุดมการณ์อันสูงส่งเช่นเดียวกัน ฉันจะมุ่งมั่นท้าทายในสิ่งที่ทุ่มเททั้งชีวิตต่อไป ขอขอบคุณและฝากเนื้อฝากตัวด้วยครับ/ค่ะ