บันทึกการฝ่า CORS และ Proxy ใน Vue 3 + Vite + Spring Boot
บทนำ
เมื่อพัฒนาโดยแยก 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 ได้ด้วย