feat: api 添加 concatDataFields 配置支持多次请求将数据字段自动合并功能 (#9038)

This commit is contained in:
liaoxuezhi 2023-12-06 15:45:17 +08:00 committed by GitHub
parent fd1d367a68
commit feba8aef2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 315 additions and 51 deletions

View File

@ -81,6 +81,40 @@ public class StreamingResponseBodyController {
需要注意有些反向代理有 buffer 设置,比如 nginx 的 [proxy_buffer_size](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size),它会使得即便后端返回内容也需要等 buffer 满了才会真正返回前端,如果需要更实时的效果就需要关掉此功能。
### 轮询方案
如果后端无法提供流的方式,也可以通过轮询的方式获取数据,比如:
```schema
{
"type": "page",
"title": "轮询日志演示,当秒钟到 59 时会停止轮询",
"body": [
{
"type": "service",
"api": {
"method": "get",
"url": "/api/mock2/log/date?offset=${offset}",
"autoRefresh": false, // 因为设置了轮询,所以关闭自动刷新
"concatDataFields": "log"
},
"silentPolling": true,
"interval": 1000,
"stopAutoRefreshWhen": "${finished}",
"body": [
{
"type": "log",
"height": 300,
"source": "${log}"
}
]
}
]
}
```
此示例利用了 Service 组件的轮询能力,并通过 `concatDataFields` 将多次返回的日志拼接起来,然后通过给 log 设置 `source` 属性来关联日志数据。
## 对于超长日志的优化
> 1.10.0 及以上版本

View File

@ -229,14 +229,17 @@ done

Done. Your build exited with 0.
`
`;
const http = require('http');
let app = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*'
'Access-Control-Allow-Origin': 'http://localhost:8888',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': '*'
});
let index = 0;
@ -246,11 +249,11 @@ let app = http.createServer((req, res) => {
if (index + random < logLength - 1) {
res.write(logs.substring(index, index + random));
} else {
res.end(logs.substring(index, logLength - 1))
res.end(logs.substring(index, logLength - 1));
clearInterval(timer);
}
index = index + random;
}, 100);
});
app.listen(3000, '127.0.0.1');
app.listen(3000, '127.0.0.1');

33
mock/cfc/mock/log/date.js Normal file
View File

@ -0,0 +1,33 @@
function pad(num) {
return num < 10 ? '0' + num : num;
}
module.exports = function (req, res) {
const now = new Date();
const seconds = now.getSeconds();
const logs = [];
let from = req.query.offset ? parseInt(req.query.offset) || 0 : 0;
const finished = seconds == 59;
for (let i = from; i <= seconds; i++) {
logs.push(
`Date: ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate() + 1
)} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(i)}\n`
);
}
if (finished) {
logs.push('Finished');
}
res.json({
status: 0,
msg: '',
data: {
offset: seconds + 1,
log: logs,
finished: finished
}
});
};

View File

@ -468,7 +468,11 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
}
self.items.replace(rowsData);
self.reInitData(data, !!(api as ApiObject).replaceData);
self.reInitData(
data,
!!(api as ApiObject).replaceData,
(api as ApiObject).concatDataFields
);
options.syncResponse2Query !== false &&
updateQuery(
pick(rest, Object.keys(self.query)),
@ -564,7 +568,8 @@ export const CRUDStore = ServiceStore.named('CRUDStore')
{
__saved: Date.now()
},
!!api && (api as ApiObject).replaceData
!!api && (api as ApiObject).replaceData,
(api as ApiObject)?.concatDataFields
);
self.updatedAt = Date.now();
}

View File

@ -164,8 +164,13 @@ export const FormStore = ServiceStore.named('FormStore')
};
})
.actions(self => {
function setValues(values: object, tag?: object, replace?: boolean) {
self.updateData(values, tag, replace);
function setValues(
values: object,
tag?: object,
replace?: boolean,
concatFields?: string | string[]
) {
self.updateData(values, tag, replace, concatFields);
// 如果数据域中有数据变化就都reset一下去掉之前残留的验证消息
self.items.forEach(item => {
@ -368,7 +373,8 @@ export const FormStore = ServiceStore.named('FormStore')
__saved: Date.now()
}
: undefined,
!!(api as ApiObject).replaceData
!!(api as ApiObject).replaceData,
(api as ApiObject).concatDataFields
);
}

View File

@ -12,7 +12,7 @@ import {SimpleMap} from '../utils/SimpleMap';
import {StoreNode} from './node';
import {IScopedContext} from '../Scoped';
import {IRootStore} from './root';
import {createObjectFromChain, extractObjectChain} from '../utils';
import {concatData, createObjectFromChain, extractObjectChain} from '../utils';
export const iRendererStore = StoreNode.named('iRendererStore')
.props({
@ -58,7 +58,16 @@ export const iRendererStore = StoreNode.named('iRendererStore')
self.data = self.pristine;
},
updateData(data: object = {}, tag?: object, replace?: boolean) {
updateData(
data: object = {},
tag?: object,
replace?: boolean,
concatFields?: string | string[]
) {
if (concatFields) {
data = concatData(data, self.data, concatFields);
}
const prev = self.data;
let newData;
if (tag) {

View File

@ -5,6 +5,7 @@ import {extendObject, isEmpty, isObject} from '../utils/helper';
import {ServerError} from '../utils/errors';
import {normalizeApiResponseData} from '../utils/api';
import {replaceText} from '../utils/replaceText';
import {concatData} from '../utils/concatData';
export const ServiceStore = iRendererStore
.named('ServiceStore')
@ -40,7 +41,14 @@ export const ServiceStore = iRendererStore
self.busying = busying;
}
function reInitData(data: object | undefined, replace: boolean = false) {
function reInitData(
data: object | undefined,
replace: boolean = false,
concatFields?: string | string[]
) {
if (concatFields) {
data = concatData(data, self.data, concatFields);
}
const newData = extendObject(self.pristine, data, !replace);
self.data = self.pristine = newData;
}
@ -106,7 +114,7 @@ export const ServiceStore = iRendererStore
...(replace ? {} : self.data),
...normalizeApiResponseData(json.data)
};
reInitData(data, replace);
reInitData(data, replace, (api as ApiObject).concatDataFields);
self.hasRemoteData = true;
if (options && options.onSuccess) {
const ret = options.onSuccess(json, data);
@ -191,7 +199,8 @@ export const ServiceStore = iRendererStore
self.updateData(
normalizeApiResponseData(json.data),
undefined,
!!(api as ApiObject).replaceData
!!(api as ApiObject).replaceData,
(api as ApiObject).concatDataFields
);
self.hasRemoteData = true;
@ -289,7 +298,8 @@ export const ServiceStore = iRendererStore
self.updateData(
normalizeApiResponseData(json.data),
undefined,
!!(api as ApiObject).replaceData
!!(api as ApiObject).replaceData,
(api as ApiObject).concatDataFields
);
}
@ -457,7 +467,8 @@ export const ServiceStore = iRendererStore
self.updateData(
json.data.data,
undefined,
!!(api as ApiObject).replaceData
!!(api as ApiObject).replaceData,
(api as ApiObject).concatDataFields
);
}
@ -516,7 +527,8 @@ export const ServiceStore = iRendererStore
self.updateData(
json.data,
undefined,
!!(api as ApiObject).replaceData
!!(api as ApiObject).replaceData,
(api as ApiObject).concatDataFields
);
if (!json.ok) {

View File

@ -720,7 +720,8 @@ export const TableStore2 = ServiceStore.named('TableStore2')
{
__saved: Date.now()
},
!!api && (api as ApiObject).replaceData
!!api && (api as ApiObject).replaceData,
(api as ApiObject)?.concatDataFields
);
self.updatedAt = Date.now();
}

View File

@ -153,6 +153,14 @@ export interface BaseApiObject {
*/
replaceData?: boolean;
/**
*
*
* log {log: '1'} {log: '2'} {log: ['1', '2']]}
* items {items: [1, 2]} {items: [3, 4]} {items: [1, 2, 3, 4]}
*/
concatDataFields?: string | Array<string>;
/**
* url
*

View File

@ -0,0 +1,35 @@
import {getVariable} from './getVariable';
import {setVariable} from './object';
/**
*
*
* @param data
* @param origin
* @param keys
*/
export function concatData(data: any, origin: any, keys: string | string[]) {
keys = Array.isArray(keys)
? keys
: keys
.split(',')
.map(item => item.trim())
.filter(item => item);
let newData = {...data};
let modified = false;
origin &&
keys.forEach(key => {
const prev = getVariable(origin, key);
const next = getVariable(data, key);
const args: any = [];
typeof prev !== 'undefined' && args.push(prev);
typeof next !== 'undefined' && args.push(next);
modified = true;
setVariable(newData, key, [].concat.apply([], args));
});
return modified ? newData : data;
}

View File

@ -3,6 +3,7 @@ export * from './attachmentAdpator';
export * from './autobind';
export * from './ColorScale';
export * from './columnsSplit';
export * from './concatData';
export * from './dataMapping';
export * from './DataSchema';
export * from './DataScope';

View File

@ -8,6 +8,7 @@ import {clearStoresCache} from '../../../src';
afterEach(() => {
cleanup();
clearStoresCache();
jest.useRealTimers();
});
test('Renderer:service', async () => {
@ -334,3 +335,67 @@ test('form:service:super-remoteData', async () => {
replaceReactAriaIds(container);
expect(container).toMatchSnapshot();
});
test('service init api with interval and concatDataFields', async () => {
jest.useFakeTimers();
let times = 0;
const fetcher = jest.fn().mockImplementation(() => {
times++;
return Promise.resolve({
data: {
status: 0,
msg: 'ok',
data: {
log: `${times}th log`,
finished: times > 2
}
}
});
});
const {container, getByText} = render(
amisRender(
{
type: 'service',
api: {
method: 'get',
url: '/api/initData',
concatDataFields: 'log'
},
interval: 3000,
stopAutoRefreshWhen: '${finished}',
body: [
{
type: 'tpl',
tpl: '${log|json}'
}
]
},
{},
makeEnv({
fetcher: fetcher
})
)
);
await wait(10, false);
jest.advanceTimersByTime(3000);
await wait(10, false);
jest.advanceTimersByTime(3000);
await wait(10, false);
jest.advanceTimersByTime(3000);
await wait(10, false);
jest.advanceTimersByTime(3000);
expect(fetcher).toHaveBeenCalledTimes(3);
await wait(200, false);
const span = container.querySelector('.cxd-TplField>span');
expect(span).toBeTruthy();
expect(JSON.parse(span?.innerHTML!)).toMatchObject([
'1th log',
'2th log',
'3th log'
]);
});

View File

@ -2,7 +2,14 @@
* @file
*/
import React from 'react';
import {buildApi, isApiOutdated, Renderer, RendererProps} from 'amis-core';
import {
buildApi,
isApiOutdated,
isEffectiveApi,
Renderer,
RendererProps,
resolveVariableAndFilter
} from 'amis-core';
import {BaseSchema} from '../Schema';
import {Icon, SearchBox, VirtualList} from 'amis-ui';
@ -154,7 +161,20 @@ export class Log extends React.Component<LogProps, LogState> {
);
}
if (this.props.source) {
this.loadLogs();
const ret = resolveVariableAndFilter(
this.props.source,
this.props.data,
'| raw'
);
if (ret && isEffectiveApi(ret)) {
this.loadLogs();
} else if (
typeof ret === 'string' ||
(Array.isArray(ret) && ret.every(item => typeof item === 'string'))
) {
this.clear();
this.addLines(Array.isArray(ret) ? ret : [ret]);
}
}
}
@ -162,15 +182,38 @@ export class Log extends React.Component<LogProps, LogState> {
if (this.autoScroll && this.logRef && this.logRef.current) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
if (
if (!this.props.source) {
return;
}
const ret = resolveVariableAndFilter(
this.props.source,
this.props.data,
'| raw'
);
if (ret && isEffectiveApi(ret)) {
// todo 如果原来的请求还在,应该先取消
isApiOutdated(
prevProps.source,
this.props.source,
prevProps.data,
this.props.data
)
) && this.loadLogs();
} else if (
typeof ret === 'string' ||
(Array.isArray(ret) && ret.every(item => typeof item === 'string'))
) {
this.loadLogs();
const prevRet = resolveVariableAndFilter(
prevProps.source,
prevProps.data,
'| raw'
);
if (prevRet !== ret && ret) {
this.clear();
this.addLines(Array.isArray(ret) ? ret : [ret]);
}
}
}
@ -194,16 +237,19 @@ export class Log extends React.Component<LogProps, LogState> {
e.preventDefault();
};
clear = (e: React.MouseEvent<HTMLElement>) => {
clear = (e?: React.MouseEvent<HTMLElement>) => {
this.setState({
logs: [],
lastLine: '',
logs: (this.logs = []),
lastLine: (this.lastLine = ''),
originLogs: [],
originLastLine: ''
});
e.preventDefault();
e?.preventDefault();
};
// 因为 this.state 取不到最新的值,所以用了这个变量
lastLine: string;
logs: string[];
filterWord = (logs: string[], lastLine: string, word: string) => {
let originLogs = logs;
let originLastLine = lastLine;
@ -215,13 +261,39 @@ export class Log extends React.Component<LogProps, LogState> {
}
this.setState({
filterWord: word,
lastLine: lastLine,
logs: logs,
lastLine: (this.lastLine = lastLine),
logs: (this.logs = logs),
originLogs: originLogs,
originLastLine: originLastLine
});
};
addLines = (lines: string[]) => {
lines = lines.concat();
const {maxLength} = this.props;
let lastLine = this.lastLine || '';
let logs = (this.logs || []).concat();
// 如果没有换行符就只更新最后一行
if (lines.length === 1) {
lastLine += lines[0];
this.setState({
lastLine: (this.lastLine = lastLine)
});
} else {
// 将之前的数据补上
lines[0] = lastLine + (lines[0] || '');
// 最后一个要么是空,要么是下一行的数据
lastLine = lines.pop() || '';
if (maxLength) {
if (logs.length + lines.length > maxLength) {
logs.splice(0, logs.length + lines.length - maxLength);
}
}
logs = logs.concat(lines);
this.filterWord(logs, lastLine, this.state.filterWord);
}
};
async loadLogs() {
const {
source,
@ -249,8 +321,6 @@ export class Log extends React.Component<LogProps, LogState> {
return;
}
const reader = body.getReader();
let lastLine = '';
let logs: string[] = [];
for (;;) {
if (!this.state.refresh) {
await reader.cancel('click cancel button').then(() => {
@ -263,25 +333,7 @@ export class Log extends React.Component<LogProps, LogState> {
let text = new TextDecoder(encoding).decode(value, {stream: true});
// 不考虑只有 \r 换行符的情况,几乎没人用
const lines = text.split('\n');
// 如果没有换行符就只更新最后一行
if (lines.length === 1) {
lastLine += lines[0];
this.setState({
lastLine: lastLine
});
} else {
// 将之前的数据补上
lines[0] = lastLine + lines[0];
// 最后一个要么是空,要么是下一行的数据
lastLine = lines.pop() || '';
if (maxLength) {
if (logs.length + lines.length > maxLength) {
logs.splice(0, logs.length + lines.length - maxLength);
}
}
logs = logs.concat(lines);
this.filterWord(logs, lastLine, this.state.filterWord);
}
this.addLines(lines);
}
if (done) {

View File

@ -502,7 +502,7 @@ export default class Service extends React.Component<ServiceProps> {
return;
}
}
store.updateData(returndata, undefined, false);
store.updateData(returndata, undefined, false, wsApi.concatDataFields);
store.setHasRemoteData();
this.runDataProvider('onWsFetched');