背景
一天下午,正认真的上(摸)班(鱼)呢,一个前端开发同事找到运维团队“后端服务是不是有什么异常啊,为什么我的访问不通呢?”“接口地址拿来~”运维工程师使用本地的postman进行调用。结果是正常返回。“我这调用没问题啊,你写的code的问题吧......”一场大战一触即发.......
这天可以记为两位工程师的历史性时刻——发现了CORS!
那么什么是CORS呢?
跨源资源共享(Cross-Origin Resource Sharing,或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问加载自己的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的“预检”请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。
看的有点懵,现在举个现实中的例子:有一位公司的老板,他有一个秘书,秘书负责在办公室接通各个客户的电话后,会询问是谁从什么地方打来的电话,然后通知老板是否愿意与他们通话。老板比较忙的时候会告诉秘书:“我今天只接受A公司XX人的电话同步的信息”。那么秘书就会按照老板的要求进行同步。但是也有特殊情况:比如B公司老板直接知道老板的电话。也会直接联系老板
从现实生活到软件工程访问,我们做一个对应:
- 给办公室打电话的人——前端应用程序
- 秘书-浏览器
- 老板-后端应用程序
访问的逐步顺序如下:
- 一旦前端应用程序尝试向后端 API 发送请求,浏览器就会向后端 API 发出所谓的预请求,并询问允许的选项:谁可以调用 API 以及可以发出什么类型的请求
- API 发送带有此类选项的响应,并且(可选)包括浏览器应缓存这些依赖设置
- 如果前端应用程序及其尝试发出的请求位于允许列表内,则浏览器会允许其通过
- 否则,请求将被拒绝,并出现我们在本文开头看到的错误
我们启动一个后端和前端来模拟问题:
后端的Go代码
package main
import (
"encoding/json"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"net/http"
)
var books = [] string { "指环王" , "霍比特人" , "精灵宝钻" }
type Book struct {
标题字符串 `json:"title"`
}
func main () {
err := runServer()
if err != nil { iferrors.Is(
err , http.ErrServerClosed ) {
fmt.Println( "服务器关闭" )
} else {
fmt.Println( "服务器失败" , err)
}
}
}
func runServer () error {
httpRouter := chi.NewRouter()
httpRouter.Route( "/api/ v1" , func (r chi.Router) {
r.Get( "/books" , getAllBooks)
r.Post( "/books" , addBook)
r.Delete( "/books" , deleteAllBooks)
})
server := &http .Server{Addr: "localhost:8888" , Handler: httpRouter}
return server.ListenAndServe()
}
func getAllBooks (w http.ResponseWriter, req *http.Request) {
respBody, err := json.Marshal(books)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set( "Content-Type" , "application/json" )
w.WriteHeader(http.StatusOK)
w.Write(respBody)
}
func addBook (w http.ResponseWriter, req *http.Request) {
var book Book
err := json.NewDecoder(req.Body).Decode(&book)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
books = append (books, book.Title)
w.WriteHeader(http.StatusCreated)
}
func deleteAllBooks (w http.ResponseWriter, req *http.Request) {
books = [] string {}
w.WriteHeader(http.StatusNoContent)
}
运行这段代码,服务器将运行为http://localhost:8888
前端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Books</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
</head>
<body>
<div class="container p-3">
<button type="button" class="btn btn-primary" id="getBooks">Get books</button>
<button type="button" class="btn btn-danger" id="deleteAllBooks">Delete all books</button>
<br>
<br>
<form>
<div class="mb-3">
<label for="inputBookTitle" class="form-label">Book title</label>
<input type="text" class="form-control" id="inputBookTitle" aria-describedby="emailHelp">
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
<script>
function getBooks () {
fetch('http://localhost:8888/api/v1/books')
.then(response => response.json())
.then(data => {
const booksList = document.querySelector('.books-list')
if (booksList) {
booksList.remove()
}
const ul = document.createElement('ul')
ul.classList.add('books-list')
data.forEach(book => {
const li = document.createElement('li')
li.innerText = book
ul.appendChild(li)
})
document.body.appendChild(ul)
})
}
function deleteAllBooks () {
fetch('http://localhost:8888/api/v1/books', {
method: 'DELETE'
})
.then(response => {
if (response.status === 204) {
getBooks()
} else {
const div = document.createElement('div')
div.innerText = 'Something went wrong'
document.body.appendChild(div)
}
})
}
const getBooksButton = document.getElementById('getBooks')
const deleteAllBooksButton = document.getElementById('deleteAllBooks')
const input = document.querySelector('input')
const form = document.querySelector('form')
getBooksButton.addEventListener('click', () => getBooks())
deleteAllBooksButton.addEventListener('click', () => deleteAllBooks())
form.addEventListener('submit', (event) => {
event.preventDefault()
const title = input.value
fetch('http://localhost:8888/api/v1/books', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ title })
})
.then(response => {
if (response.status === 201) {
input.value = ''
getBooks()
} else {
const div = document.createElement('div')
div.innerText = 'Something wend wrong'
document.body.appendChild(div)
}
})
})
</script>
</body>
</html>
一个Go 服务(与index.html放在一个文件夹下):
package main
import (
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"net/http"
)
func main() {
err := runServer()
if err != nil {
if errors.Is(err, http.ErrServerClosed) {
fmt.Println("client server shutdown")
} else {
fmt.Println("client server failed", err)
}
}
}
func runServer() error {
httpRouter := chi.NewRouter()
httpRouter.Get("/", serveIndex)
server := &http.Server{Addr: "localhost:3333", Handler: httpRouter}
return server.ListenAndServe()
}
func serveIndex(w http.ResponseWriter, req *http.Request) {
http.ServeFile(w, req, "./index.html")
}
运行这段代码,前端html将运行为http://localhost:3333
使用浏览器访问,得到如下页面,打开F12调试,在文本框中输入书名,点击Add:
得到了与文章开始时类似的报错。
您可能已经发现,我们的后端代码根本没有提及 CORS。确实如此,到目前为止我们还没有实现任何 CORS 配置。但这对于浏览器来说并不重要:它无论如何都会尝试发出预检请求。(就像秘书一定要征求老板的意见,不会擅自决定)
如果我们单击405这个报错,会展开一些详细信息,我们可以看到浏览器尝试向与添加图书端点相同的路径发出 OPTIONS 请求,并收到响应405 Method Not Allowed,这是有道理的,因为我们还没有定义我们后端的 OPTIONS 端点。
问题解决
前端应用程序保持不变,但对于后端,我们需要进行一些更改:
引入一个新函数来启用 CORS:
func enableCors (w http.ResponseWriter) {
// 指定允许哪些域访问此 API
w.Header().Set( "Access-Control-Allow-Origin" , "http://localhost:3333" )
//指定允许哪些方法访问此 API
w.Header().Set( "Access-Control-Allow-Methods" , "GET, POST, DELETE" )
// 指定允许哪些标头访问此 API
w.Header( ).Set( "Access-Control-Allow-Headers" , "Accept, Content-Type" )
// 指定浏览器可以缓存预检请求结果的时间(以秒为单位)
w.Header().Set( “访问控制最大时间”,strconv.Itoa( 60 * 60 * 2))
}
在现有端点旁边引入一个 OPTIONS 端点以及一个处理它的函数:
...
httpRouter.Route( "/api/v1" , func (r chi.Router) {
r.Options( "/books" , corsOptions)
r.Get( "/books" , getAllBooks)
r.Post( "/ books" , addBook)
r.Delete( "/books" , deleteAllBooks)
})
...
func corsOptions (w http.ResponseWriter, req *http.Request) {
enableCors(w)
w.WriteHeader(http.StatusOK)
}
添加enableCors对其他端点现有函数的调用,例如:
func getAllBooks (w http.ResponseWriter, req *http.Request) {
respBody, err := json.Marshal(books)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
enableCors(w)
w.Header( ).Set( "Content-Type" , "application/json" )
w.WriteHeader(http.StatusOK)
w.Write(respBody)
}
最后的后端代码如下:
package main
import (
"encoding/json"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"net/http"
"strconv"
)
var books = []string{"The Lord of the Rings", "The Hobbit", "The Silmarillion"}
type Book struct {
Title string `json:"title"`
}
func main() {
err := runServer()
if err != nil {
if errors.Is(err, http.ErrServerClosed) {
fmt.Println("server shutdown")
} else {
fmt.Println("server failed", err)
}
}
}
func runServer() error {
httpRouter := chi.NewRouter()
httpRouter.Route("/api/v1", func(r chi.Router) {
r.Options("/books", corsOptions)
r.Get("/books", getAllBooks)
r.Post("/books", addBook)
r.Delete("/books", deleteAllBooks)
})
server := &http.Server{Addr: "localhost:8888", Handler: httpRouter}
return server.ListenAndServe()
}
func corsOptions(w http.ResponseWriter, req *http.Request) {
enableCors(w)
w.WriteHeader(http.StatusOK)
}
func getAllBooks(w http.ResponseWriter, req *http.Request) {
respBody, err := json.Marshal(books)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
enableCors(w)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(respBody)
}
func addBook(w http.ResponseWriter, req *http.Request) {
var book Book
err := json.NewDecoder(req.Body).Decode(&book)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
books = append(books, book.Title)
enableCors(w)
w.WriteHeader(http.StatusCreated)
}
func deleteAllBooks(w http.ResponseWriter, req *http.Request) {
books = []string{}
enableCors(w)
w.WriteHeader(http.StatusNoContent)
}
func enableCors(w http.ResponseWriter) {
// specifies which domains are allowed to access this API
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3333")
// specifies which methods are allowed to access this API (GET is allowed by default)
w.Header().Set("Access-Control-Allow-Methods", "POST, DELETE")
// specifies which headers are allowed to access this API
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// specifies for how long the browser can cache the results of a preflight request (in seconds)
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(60*60*2))
}
重新启动前端和后端,重新尝试访问会发现问题解决了~
其中重要的部分是Response headers
如果尝试改变后端配置。允许访问的地址改为http://localhost:33333:
此时再去访问则发现:
此时就是后端的配置导致的。当人你也可以更改其他的配置做一些尝试。
我们到这就理解了CORS是一种允许当前域(domain)的资源(比如http://localhost:8888)被其他域(http://localhost:3333)的脚本请求访问的机制,通常由于同域安全策略(the same-origin security policy)浏览器会禁止这种跨域请求。当浏览器发出PUT请求,OPTION(预检)请求返回Access-Control-Allow-Origin:http://localhost:3333,Access-Control-Allow-Methods:’PUT’,服务器同意指定域的PUT请求,浏览器收到并继续发出真正的PUT请求,服务器响应并再次返回Access-Control-Allow-Origin:http://localhost:3333,允许浏览器的脚本执行服务器返回的数据。
希望能对您有帮助!
参考:https://itnext.io/understanding-cors-4157bf640e11