diff --git a/data/config.example.ini b/data/config.example.ini index 461a811..39f11cd 100644 --- a/data/config.example.ini +++ b/data/config.example.ini @@ -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+) diff --git a/frontend/.env b/frontend/.env index e90e223..bccba01 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1,6 @@ +# 第三方库用CDN引入 LIB_USE_CDN=false +# production环境生成source map PROD_SOURCE_MAP=true +# 动态发现后端endpoint +BACKEND_DISCOVERY=false diff --git a/frontend/.env.common_server b/frontend/.env.common_server index ae6be49..f042bef 100644 --- a/frontend/.env.common_server +++ b/frontend/.env.common_server @@ -1,3 +1,4 @@ NODE_ENV=production LIB_USE_CDN=true PROD_SOURCE_MAP=false +BACKEND_DISCOVERY=true diff --git a/frontend/package.json b/frontend/package.json index 087de45..2b72a75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/base.js b/frontend/src/api/base.js new file mode 100644 index 0000000..44d7122 --- /dev/null +++ b/frontend/src/api/base.js @@ -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() +} diff --git a/frontend/src/api/chat/ChatClientDirectOpenLive.js b/frontend/src/api/chat/ChatClientDirectOpenLive.js index bae289d..75a750f 100644 --- a/frontend/src/api/chat/ChatClientDirectOpenLive.js +++ b/frontend/src/api/chat/ChatClientDirectOpenLive.js @@ -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' diff --git a/frontend/src/api/chat/ChatClientDirectWeb.js b/frontend/src/api/chat/ChatClientDirectWeb.js index 869fc06..7030f61 100644 --- a/frontend/src/api/chat/ChatClientDirectWeb.js +++ b/frontend/src/api/chat/ChatClientDirectWeb.js @@ -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' diff --git a/frontend/src/api/chat/ChatClientRelay.js b/frontend/src/api/chat/ChatClientRelay.js index df4abcb..e5cfde1 100644 --- a/frontend/src/api/chat/ChatClientRelay.js +++ b/frontend/src/api/chat/ChatClientRelay.js @@ -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) diff --git a/frontend/src/api/chat/index.js b/frontend/src/api/chat/index.js index db2090d..43399c0 100644 --- a/frontend/src/api/chat/index.js +++ b/frontend/src/api/chat/index.js @@ -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 { diff --git a/frontend/src/api/main.js b/frontend/src/api/main.js index fd9571a..4fd822a 100644 --- a/frontend/src/api/main.js +++ b/frontend/src/api/main.js @@ -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 diff --git a/frontend/src/api/plugins.js b/frontend/src/api/plugins.js index c293e75..0a60d8b 100644 --- a/frontend/src/api/plugins.js +++ b/frontend/src/api/plugins.js @@ -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 diff --git a/frontend/src/main.js b/frontend/src/main.js index 48ce685..5412a32 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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) diff --git a/frontend/vue.config.js b/frontend/vue.config.js index 99634c7..79e4254 100644 --- a/frontend/vue.config.js +++ b/frontend/vue.config.js @@ -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 => {