前端动态获取后端地址、支持故障转移

This commit is contained in:
John Smith 2024-11-08 00:18:16 +08:00
parent 99d3b567da
commit cefca66d76
13 changed files with 191 additions and 15 deletions

View File

@ -270,9 +270,9 @@ temperature = 0.4
# 用于服务发现返回的后端端点
[registered_endpoints]
# 1 = https://api1.blive.chat
1 = http://localhost:12450
# 允许跨域的源,正则表达式
[cors_origins]
# 1 = https://(?:|.+\.)blive\.chat
1 = http://localhost(:\d+)

View File

@ -1,2 +1,6 @@
# 第三方库用CDN引入
LIB_USE_CDN=false
# production环境生成source map
PROD_SOURCE_MAP=true
# 动态发现后端endpoint
BACKEND_DISCOVERY=false

View File

@ -1,3 +1,4 @@
NODE_ENV=production
LIB_USE_CDN=true
PROD_SOURCE_MAP=false
BACKEND_DISCOVERY=true

View File

@ -1,6 +1,6 @@
{
"name": "blivechat",
"version": "1.9.2",
"version": "1.9.3-dev",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@ -15,6 +15,7 @@
"downloadjs": "^1.4.7",
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"opossum": "^8.3.0",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",

165
frontend/src/api/base.js Normal file
View File

@ -0,0 +1,165 @@
import axios from 'axios'
import _ from 'lodash'
import CircuitBreaker from 'opossum'
axios.defaults.timeout = 10 * 1000
export const apiClient = axios.create({
timeout: 10 * 1000,
})
export let getBaseUrl
if (!process.env.BACKEND_DISCOVERY) {
const onRequest = config => {
config.baseURL = getBaseUrl()
return config
}
const onRequestError = e => {
throw e
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
getBaseUrl = function() {
return window.location.origin
}
} else {
const onRequest = config => {
let baseUrl = getBaseUrl()
if (baseUrl === null) {
throw new Error('No available endpoint')
}
config.baseURL = baseUrl
return config
}
const onRequestError = e => {
throw e
}
const onResponse = response => {
let promise = Promise.resolve(response)
let baseUrl = response.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
return response
}
const onResponseError = e => {
let promise = Promise.reject(e)
if (!e.response || (500 <= e.response.status && e.response.status < 600)) {
let baseUrl = e.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
}
return promise
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
apiClient.interceptors.response.use(onResponse, onResponseError)
const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [
// 只有公共服务器会开BACKEND_DISCOVERY这里可以直接跨域访问
'https://api1.blive.chat/api/endpoints',
'https://api2.blive.chat/api/endpoints',
] : [
`${window.location.origin}/api/endpoints`,
'http://localhost:12450/api/endpoints',
]
let baseUrls = process.env.NODE_ENV === 'production' ? [
'https://api1.blive.chat',
'https://api2.blive.chat',
] : [
window.location.origin,
'http://localhost:12450',
]
let curBaseUrl = null
let baseUrlToCircuitBreaker = new Map()
const doUpdateBaseUrls = async() => {
async function requestGetUrls(discoveryUrl) {
try {
return (await axios.get(discoveryUrl)).data.endpoints
} catch (e) {
console.warn('Failed to discover server endpoints from one source:', e)
throw e
}
}
let _baseUrls = []
try {
let promises = DISCOVERY_URLS.map(requestGetUrls)
_baseUrls = await Promise.any(promises)
} catch {
}
if (_baseUrls.length === 0) {
console.error('Failed to discover server endpoints from any source')
return
}
// 按响应时间排序
let sortedBaseUrls = []
let errorBaseUrls = []
async function testEndpoint(baseUrl) {
try {
let url = `${baseUrl}/api/server_info`
await axios.get(url, { timeout: 3 * 1000 })
sortedBaseUrls.push(baseUrl)
} catch {
errorBaseUrls.push(baseUrl)
}
}
await Promise.all(_baseUrls.map(testEndpoint))
sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls)
baseUrls = sortedBaseUrls
if (baseUrls.indexOf(curBaseUrl) === -1) {
curBaseUrl = null
}
console.log('Found server endpoints:', baseUrls)
}
const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000)
getBaseUrl = function() {
updateBaseUrls()
if (curBaseUrl !== null) {
let breaker = getOrAddCircuitBreaker(curBaseUrl)
if (!breaker.opened) {
return curBaseUrl
}
curBaseUrl = null
}
// 找第一个未熔断的
for (let baseUrl of baseUrls) {
let breaker = getOrAddCircuitBreaker(baseUrl)
if (!breaker.opened) {
curBaseUrl = baseUrl
console.log('Switch server endpoint to', curBaseUrl)
return curBaseUrl
}
}
return null
}
const getOrAddCircuitBreaker = baseUrl => {
let breaker = baseUrlToCircuitBreaker.get(baseUrl)
if (breaker === undefined) {
breaker = new CircuitBreaker(promise => promise, {
timeout: false,
rollingCountTimeout: 60 * 1000,
errorThresholdPercentage: 70,
resetTimeout: 60 * 1000,
})
baseUrlToCircuitBreaker.set(baseUrl, breaker)
}
return breaker
}
await updateBaseUrls()
}

View File

@ -1,5 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'

View File

@ -1,5 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'

View File

@ -1,3 +1,4 @@
import { getBaseUrl } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
@ -52,8 +53,15 @@ export default class ChatClientRelay {
this.addDebugMsg('Connecting')
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${protocol}://${window.location.host}/api/chat`
let baseUrl = getBaseUrl()
if (baseUrl === null) {
this.addDebugMsg('No available endpoint')
window.setTimeout(() => this.onWsClose(), 0)
return
}
let url = baseUrl.replace(/^http(s?):/, 'ws$1:')
url += '/api/chat'
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)

View File

@ -1,6 +1,7 @@
import axios from 'axios'
import MD5 from 'crypto-js/md5'
import { apiClient as axios } from '@/api/base'
export function getDefaultMsgHandler() {
let dummyFunc = () => {}
return {

View File

@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getServerInfo() {
return (await axios.get('/api/server_info')).data

View File

@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getPlugins() {
return (await axios.get('/api/plugin/plugins')).data

View File

@ -1,6 +1,5 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import axios from 'axios'
import ElementUI from 'element-ui'
if (!process.env.LIB_USE_CDN) {
import('element-ui/lib/theme-chalk/index.css')
@ -10,8 +9,6 @@ import * as i18n from './i18n'
import App from './App'
import NotFound from './views/NotFound'
axios.defaults.timeout = 10 * 1000
if (!process.env.LIB_USE_CDN) {
Vue.use(VueRouter)
Vue.use(ElementUI)

View File

@ -33,6 +33,7 @@ module.exports = defineConfig({
const ENV = {
APP_VERSION,
LIB_USE_CDN,
BACKEND_DISCOVERY: toBool(process.env.BACKEND_DISCOVERY),
}
config.plugin('define')
.tap(args => {