Con la evolución de la tecnología de big data y la creciente demanda de seguridad de la información, la expansión continua de la escala de datos ha traído graves desafíos al trabajo de operación y mantenimiento de datos. Ante la fuerte presión administrativa causada por datos masivos, el personal de operación y mantenimiento enfrenta cuellos de botella en la eficiencia, y los crecientes costos laborales hacen que ya no sea práctico depender únicamente de la expansión del equipo de operación y mantenimiento para resolver los problemas.
Se puede ver que la inteligencia, la eficiencia y la conveniencia son direcciones inevitables para el desarrollo de la operación y el mantenimiento. La función de informe de inspección lanzada por Kangaroo Cloud es precisamente para cumplir con este objetivo y se compromete a brindar soluciones optimizadas.
¿Qué es un informe de inspección?
El informe de inspección se refiere al proceso de realizar una inspección integral de un determinado sistema o equipo y organizar los resultados y sugerencias de la inspección en un informe. Los informes de inspección generalmente se utilizan para evaluar el estado operativo y el rendimiento de sistemas o equipos, proporcionando referencia para identificar problemas, optimizar sistemas, mejorar la eficiencia y reducir las tasas de falla.
Este artículo detallará las diversas características funcionales del informe de inspección y su plan de implementación, proporcionando una referencia práctica para los usuarios con tales necesidades.
Función de implementación del informe de inspección.
● Diseño personalizado
· Los paneles del informe se pueden arrastrar y soltar para cambiar el diseño
· Limite el área de arrastre durante el proceso de arrastre. Solo se permite el arrastre dentro del mismo nivel principal. No se permiten cambios de nivel de directorio, como mover un directorio de primer nivel a otro directorio de primer nivel. Conviértete en un directorio secundario
● El directorio se puede reducir y ampliar.
· El directorio admite reducción y expansión. Cuando se reduce, todos los subpaneles se ocultan y, cuando se expande, se muestran todos los subpaneles.
· Al mover un directorio, el subpanel seguirá el movimiento
· Después de cambiar el directorio, el panel de directorio a la derecha se actualizará simultáneamente.
· Generar número de catálogo
● Árbol de directorios a la derecha
· Generar número de catálogo
· Soporte de desplazamiento de ancla
· Soporte de expansión y contracción.
· Vinculado con el informe de la izquierda.
● Panel de datos
· Obtener datos del indicador según el rango de fechas
· Mostrar información del indicador en forma de gráficos.
· Ver detalles, eliminar
· Solicitar diseño para cada panel para soportar solicitudes de actualización
●Importación de paneles
· Contar el número de paneles seleccionados en el catálogo.
· Al importar un panel nuevo, el diseño existente no se puede destruir y el panel nuevo solo puede seguir al panel anterior.
· Al importar un panel existente, es necesario realizar una comparación de datos . Si hay cambios en los datos, es necesario obtener los datos más recientes.
● Guardar
Antes de guardar, todas las operaciones relacionadas con el diseño son temporales, incluida la importación de paneles. Solo después de hacer clic en Guardar, los datos actuales se enviarán al backend para guardarlos.
● Admite exportación de PDF y Word
Plan de implementación del informe de inspección
Entonces, ¿cómo se implementa este conjunto de funciones de informe de inspección? A continuación se presentará cada aspecto del diseño de la estructura de datos , diseño de componentes , directorio, panel, etc.
Diseño de estructura de datos.
Primero veamos el diagrama usando una estructura plana:
En una estructura plana, solo necesita encontrar el panel de la siguiente fila para determinar el hijo . Lo mismo ocurre con los directorios de varios niveles, pero el directorio de primer nivel requiere procesamiento adicional.
Aunque la estructura plana es relativamente simple de implementar, para satisfacer necesidades específicas, es decir, limitar la tarea de arrastrar y soltar directorios. Restringir el directorio requiere una relación jerárquica del panel relativamente clara. Obviamente, la estructura de datos del árbol puede describir la estructura jerárquica de un dato de manera muy apropiada y clara.
Diseño de componentes
Es diferente de la programación de componentes tradicional. En términos de implementación, la representación y el procesamiento de datos se separan y dividen en dos partes:
· Componente React: principal responsable de la representación de la página.
· Clase: Responsable del procesamiento de datos
Modelo de tablero
class DashboardModel {
id: string | number;
panels: PanelModel[]; // 各个面板
// ...
}
Modelo de panel
class PanelModel {
key?: string;
id!: number;
gridPos!: GridPos; // 位置信息
title?: string;
type: string;
panels: PanelModel[]; // 目录面板需要维护当前目录下的面板信息
// ...
}
Cada componente del Panel corresponde a un DashboardModel y cada componente del Panel corresponde a un PanelModel .
Los componentes de React se representan en función de los datos de la instancia de clase. Una vez producida la instancia, no se destruirá fácilmente ni se cambiará la dirección de referencia. Esto evita que los componentes de React que dependen de los datos de la instancia para la renderización activen la renderización de actualización.
Necesitamos una forma de activar manualmente la representación de actualización del componente después de que cambien los datos de la instancia.
● Control de renderizado de componentes
Dado que usamos el componente Hooks antes, a diferencia del componente Class, el componente se puede activar llamando al método forceUpdate.
Hay una nueva característica en reaccionar18, useSyncExternalStore , que nos permite suscribirnos a datos externos. Si los datos cambian, activará la representación del componente.
De hecho, el principio de useSyncExternalStore que activa la representación de componentes es mantener un estado internamente. Cuando se cambia el valor del estado, provoca la representación de componentes externos.
Basándonos en esta idea, simplemente implementamos un método useForceUpdate que puede activar la representación de componentes .
export function useForceUpdate() {
const [_, setValue] = useState(0);
return debounce(() => setValue((prevState) => prevState + 1), 0);
}
Aunque useForceUpdate está implementado, en el uso real, el evento debe eliminarse cuando se destruye el componente. UseSyncExternalStore se ha implementado internamente y se puede utilizar directamente.
useSyncExternalStore(dashboard?.subscribe ?? (() => {}), dashboard?.getSnapshot ?? (() => 0));
useSyncExternalStore(panel?.subscribe ?? (() => {}), panel?.getSnapshot ?? (() => 0));
Según el uso de useSyncExternalStore, se agregan los métodos subscribe y getSnapshot respectivamente .
class DashboardModel { // PanelModel 一样
count = 0;
forceUpdate() {
this.count += 1;
eventEmitter.emit(this.key);
}
/**
* useSyncExternalStore 的第一个入参,执行 listener 可以触发组件的重渲染
* @param listener
* @returns
*/
subscribe = (listener: () => void) => {
eventEmitter.on(this.key, listener);
return () => {
eventEmitter.off(this.key, listener);
};
};
/**
* useSyncExternalStore 的第二个入参,count 在这里改变后触发diff的通过。
* @param listener
* @returns
*/
getSnapshot = () => {
return this.count;
};
}
Cuando se cambian los datos y es necesario activar la representación del componente, simplemente ejecute forceUpdate.
panel
● Arrastre del panel
Los complementos de arrastrar y soltar más populares del mercado incluyen los siguientes:
· reaccionar-hermosa-dnd
· reaccionar-dnd
· diseño de cuadrícula de reacción
Después de la comparación, se descubrió que reaccionar-grid-layout es muy adecuado para la función de arrastrar y soltar del panel. React-grid-layout en sí es fácil de usar y básicamente no existe un umbral para comenzar. Finalmente, decidí usar reaccionar-grid-layout. Puede encontrar instrucciones detalladas en este enlace: https://github.com/react-grid-layout/react-grid-layout
Después de cambiar el diseño del panel, se activa el método onLayoutChange de react-grid-layout para obtener los datos de posición más recientes de todos los paneles después del diseño.
const onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
for (const newPos of newLayout) {
panelMap[newPos.i!].updateGridPos(newPos);
}
dashboard!.sortPanelsByGridPos();
};
PanelMap es un mapa, la clave es Panel.key y el valor es el panel, que está listo cuando se procesa nuestro componente.
const panelMap: Record<PanelModel['key'], PanelModel> = {};
Para actualizar los datos de diseño del panel, puede ubicar con precisión el panel correspondiente a través de PanelMap y luego llamar a su método updateGridPos para realizar la operación de actualización del diseño.
En este punto, solo hemos completado la actualización de datos del panel en sí y también necesitamos ejecutar el método sortPanelsByGridPos del panel para ordenar todos los paneles.
class DashboardModel {
sortPanelsByGridPos() {
this.panels.sort((panelA, panelB) => {
if (panelA.gridPos.y === panelB.gridPos.y) {
return panelA.gridPos.x - panelB.gridPos.x;
} else {
return panelA.gridPos.y - panelB.gridPos.y;
}
});
}
// ...
}
●Rango de arrastre del panel
El rango de arrastre actual es todo el tablero, que se puede arrastrar a voluntad. El verde es el área que se puede arrastrar del tablero y el gris es el panel. como sigue:
Si se necesitan restricciones, es necesario cambiarlas a la estructura como se muestra a continuación:
Según el original, está dividido en directorios. El verde es el área móvil general y el amarillo es el bloque de directorio de primer nivel. Al arrastrar, se utiliza todo el bloque amarillo. y el morado es el directorio de segundo nivel. Los bloques se pueden arrastrar dentro del área amarilla actual y no se pueden separar del bloque amarillo actual. Los paneles grises solo se pueden arrastrar al directorio actual.
Debe transformarse en función de la estructura de datos original:
class PanelModel {
dashboard?: DashboardModel; // 当前目录下的 dashboard
// ...
}
● Importar diseño de panel.
Los datos devueltos por el backend son un árbol con tres niveles. Una vez obtenidos, mantenemos los datos en tres mapas: ModuleMap, DashboardMap y PanelMap.
import { createContext } from 'react';
export interface Module { // 一级目录
key: string;
label: string;
dashboards?: string[];
sub_module?: Dashboard[];
}
export interface Dashboard { // 二级目录
key: string;
dashboard_key: string;
label: string;
panels?: number[];
selectPanels?: number[];
metrics?: Panel[];
}
export interface Panel {
expr: Expr[]; // 数据源语句信息
label: string;
panel_id: number;
}
type Expr = {
expr: string;
legendFormat: string;
};
export const DashboardContext = createContext({
moduleMap: new Map<string, Module>(),
dashboardMap: new Map<string, Dashboard>(),
panelMap: new Map<number, Panel>(),
});
Cuando renderizamos el módulo, recorremos ModuleMap y buscamos el directorio secundario a través de la información de los paneles en el Módulo.
Configure el directorio de primer nivel para que no se pueda seleccionar en la interacción. Cuando se selecciona el directorio de segundo nivel, los paneles relevantes se encuentran a través de los paneles del Panel del directorio secundario y se muestran en el área derecha.
Para las operaciones de estos tres mapas, se mantienen en useHandleData y se exportan:
{
...map, // moduleMap、dashboardMap、panelMap
getData, // 生成巡检报告的数据结构
init: initData, // 初始化 Map
}
●Relleno de selección de panel
Al ingresar a la administración de paneles, es necesario rellenar los paneles seleccionados. Podemos obtener la información del informe de inspección actual a través de getSaveModel y almacenar la información seleccionada correspondiente en selectPanels.
Ahora solo nos falta cambiar el valor en selectPanels para seleccionar el panel correspondiente.
● Restablecimiento de la selección del panel
Recorra DashboardMap directamente y restablezca cada panel de selección.
dashboardMap.forEach((dashboard) => {
dashboard.selectPanels = [];
});
● Inserción de paneles
Después de seleccionar el panel, existen varias situaciones al insertar el panel seleccionado:
· Esta vez también se selecciona el panel que existía originalmente en el informe de inspección . Los datos se compararán al insertarlos. Si los datos cambian, deben solicitarse y representarse en función de la información más reciente de la fuente de datos.
· Los paneles que existían originalmente en el informe de inspección no se seleccionan esta vez. Al insertar, los paneles no seleccionados deben eliminarse.
· El panel recién seleccionado se insertará al final del directorio correspondiente al insertar
Agregar un nuevo panel requiere algo similar a la reducción de directorios , excepto:
· La reducción de directorios apunta a un solo directorio, mientras que la inserción apunta a todo el directorio.
· La reducción del directorio surge directamente desde los nodos secundarios, mientras que la inserción comienza desde el nodo raíz y se inserta hacia abajo. Una vez completada la inserción, el diseño se actualiza según los datos más recientes del directorio.
class DashboardModel {
update(panels: PanelData[]) {
this.updatePanels(panels); // 更新面板
this.resetDashboardGridPos(); // 重新布局
this.forceUpdate();
}
/**
* 以当前与传入的进行对比,以传入的数据为准,并在当前的顺序上进行修改
* @param panels
*/
updatePanels(panels: PanelData[]) {
const panelMap = new Map();
panels.forEach((panel) => panelMap.set(panel.id, panel));
this.panels = this.panels.filter((panel) => {
if (panelMap.has(panel.id)) {
panel.update(panelMap.get(panel.id));
panelMap.delete(panel.id);
return true;
}
return false;
});
panelMap.forEach((panel) => {
this.addPanel(panel);
});
}
addPanel(panelData: any) {
this.panels = [...this.panels, new PanelModel({ ...panelData, top: this })];
}
resetDashboardGridPos(panels: PanelModel[] = this.panels) {
let sumH = 0;
panels?.forEach((panel: any | PanelModel) => {
let h = ROW_HEIGHT;
if (isRowPanel(panel)) {
h += this.resetDashboardGridPos(panel.dashboard.panels);
} else {
h = panel.getHeight();
}
const gridPos = {
...panel.gridPos,
y: sumH,
h,
};
panel.updateGridPos({ ...gridPos });
sumH += h;
});
return sumH;
}
}
class PanelModel {
/**
* 更新
* @param panel
*/
update(panel: PanelData) {
// 数据源语句发生变化需要重新获取数据
if (this.target !== panel.target) {
this.needRequest = true;
}
this.restoreModel(panel);
if (this.dashboard) {
this.dashboard.updatePanels(panel.panels ?? []);
}
this.needRequest && this.forceUpdate();
}
}
● Solicitud de panel
needRequest controla si el panel necesita realizar una solicitud. Si es verdadero, la solicitud se realizará la próxima vez que se presente el panel y el procesamiento de la solicitud también se colocará en PanelModel.
import { Params, params as fetchParams } from '../../components/useParams';
class PanelModel {
target: string; // 数据源信息
getParams() {
return {
targets: this.target,
...fetchParams,
} as Params;
}
request = () => {
if (!this.needRequest) return;
this.fetchData(this.getParams());
};
fetchData = async (params: Params) => {
const data = await this.fetch(params);
this.data = data;
this.needRequest = false;
this.forceUpdate();
};
fetch = async (params: Params) => { /* ... */ }
}
Nuestros componentes de representación de datos generalmente tienen un nivel profundo y se requieren parámetros externos, como intervalos de tiempo, al realizar solicitudes. Estos parámetros se mantienen en forma de variables globales y useParams. El componente superior utiliza cambios para modificar parámetros y el componente de representación de datos realiza solicitudes basadas en los parámetros arrojados.
export let params: Params = {
decimal: 1,
unit: null,
};
function useParams() {
const change = (next: (() => Params) | Params) => {
if (typeof next === 'function') params = next();
params = { ...params, ...next } as Params;
};
return { params, change };
}
export default useParams;
● Actualización del panel
Busque hacia abajo desde el nodo raíz para encontrar el nodo hoja y activar la solicitud correspondiente.
class DashboardModel {
/**
* 刷新子面板
*/
reloadPanels() {
this.panels.forEach((panel) => {
panel.reload();
});
}
}
class PanelModel {
/**
* 刷新
*/
reload() {
if (isRowPanel(this)) {
this.dashboard.reloadPanels();
} else {
this.reRequest();
}
}
reRequest() {
this.needRequest = true;
this.request();
}
}
● Eliminación de paneles
Para eliminar un panel, solo necesitamos eliminarlo en el Panel correspondiente. Después de la eliminación, se cambiará la altura actual del Panel. Este proceso es consistente con la reducción del directorio a continuación.
class DashboardModel {
/**
* @param panel 删除的面板
*/
removePanel(panel: PanelModel) {
this.panels = this.filterPanelsByPanels([panel]);
// 冒泡父容器,减少的高度
const h = -panel.gridPos.h;
this.top?.changeHeight(h);
this.forceUpdate();
}
/**
* 根据传入的面板进行过滤
* @param panels 需要过滤的面板数组
* @returns 过滤后的面板
*/
filterPanelsByPanels(panels: PanelModel[]) {
return this.panels.filter((panel) => !panels.includes(panel));
}
// ...
}
● Guardar el panel
Después de comunicarse con el backend, el frontend mantiene de forma independiente la estructura de datos del informe de inspección actual y, finalmente, se proporciona una cadena al backend. Obtenga los datos del panel actual y conviértalos con JSON.
El proceso de adquisición de información del panel comienza desde el nodo raíz, atraviesa los nodos hoja y luego comienza desde los nodos hoja y regresa hacia arriba capa por capa, que es el proceso de retroceso.
class DashboardModel {
/**
* 获取所有面板数据
* @returns
*/
getSaveModel() {
const panels: PanelData[] = this.panels.map((panel) => panel.getSaveModel());
return panels;
}
// ...
}
// 最终保存时所需要的属性,其他的都不需要
const persistedProperties: { [str: string]: boolean } = {
id: true,
title: true,
type: true,
gridPos: true,
collapsed: true,
target: true,
};
class PanelModel {
/**
* 获取所有面板数据
* @returns
*/
getSaveModel() {
const model: any = {};
for (const property in this) {
if (persistedProperties[property] && this.hasOwnProperty(property)) {
model[property] = cloneDeep(this[property]);
}
}
model.panels = this.dashboard?.getSaveModel() ?? [];
return model;
}
// ...
}
● Visualización de detalles del panel
Al visualizar el panel, puede modificar la hora, etc. Estas operaciones afectarán los datos de la instancia y deberá distinguir los datos originales de los datos de los detalles.
Al regenerar una instancia de PanelModel a partir de los datos del panel original, cualquier operación en esta instancia no afectará los datos originales.
const model = panel.getSaveModel();
const newPanel = new PanelModel({ ...model, top: panel.top }); // 创建一个新的实例
setEditPanel(newPanel); // 设置为详情
En dom, la página de detalles utiliza posicionamiento absoluto y cubre el informe de inspección.
Tabla de contenido
● Reducir y expandir el directorio
Mantenga una propiedad contraída para que el panel del directorio controle la ocultación y visualización del panel.
class PanelModel {
collapsed?: boolean; // type = row
// ...
}
// 组件渲染
{!collapsed && <DashBoard dashboard={panel.dashboard} serialNumber={serialNumber} />}
Cuando reduce y expande el directorio, cambiará su altura. Ahora necesita sincronizar esta altura modificada con el panel de nivel superior.
Lo que debe hacer el nivel superior es similar al procesamiento de nuestro directorio de control. De la siguiente manera, controle la reducción del primer directorio secundario :
Cuando se producen cambios en el panel, es necesario notificar al panel superior y realizar las operaciones correspondientes.
Agregue un top para obtener la instancia principal .
class DashboardModel {
top?: null | PanelModel; // 最近的 panel 面板
/**
* 面板高度变更,同步修改其他面板进行对应高度 Y 轴的变更
* @param row 变更高度的 row 面板
* @param h 变更高度
*/
togglePanelHeight(row: PanelModel, h: number) {
const rowIndex = this.getIndexById(row.id);
for (let panelIndex = rowIndex + 1; panelIndex < this.panels.length; panelIndex++) {
this.panels[panelIndex].gridPos.y += h;
}
this.panels = [...this.panels];
// 顶级 dashBoard 容器没有 top
this.top?.changeHeight(h);
this.forceUpdate();
}
// ...
}
class PanelModel {
top: DashboardModel; // 最近的 dashboard 面板
/**
* @returns h 展开收起影响的高度
*/
toggleRow() {
this.collapsed = !this.collapsed;
let h = this.dashboard?.getHeight();
h = this.collapsed ? -h : h;
this.changeHeight(h);
}
/**
*
* @param h 变更的高度
*/
changeHeight(h: number) {
this.updateGridPos({ ...this.gridPos, h: this.gridPos.h + h }); // 更改自身面板的高度
this.top.togglePanelHeight(this, h); // 触发父级变更
this.forceUpdate();
}
// ...
}
Organice procesos y tipos de burbujeo, hasta el panel de control de nivel superior. La expansión y la contracción son lo mismo.
● Representación del directorio correcto
Punto de anclaje/número de serie
· El punto de anclaje utiliza Anchor + id para seleccionar el componente
· Los números de serie se generan en función de cada representación.
Utilice publicar y suscribirse para gestionar el renderizado.
Cada vez que el panel cambia el diseño, el directorio de la derecha debe actualizarse sincrónicamente y es posible que cualquier panel deba activar la actualización del directorio de la derecha.
Si mantenemos los eventos de renderizado de los componentes correspondientes dentro de la instancia, existen dos problemas:
· Es necesario distinguir, por ejemplo, al actualizar el panel, no es necesario activar la representación del directorio de la derecha
· Cómo se suscribe cada panel a los eventos de representación del directorio de la derecha
Finalmente, se adoptó el modelo publicación-suscriptor para la gestión de eventos.
class EventEmitter {
list: Record<string, any[]> = {};
/**
* 订阅
* @param event 订阅事件
* @param fn 订阅事件回调
* @returns
*/
on(event: string, fn: () => void) {}
/**
* 取消订阅
* @param event 订阅事件
* @param fn 订阅事件回调
* @returns
*/
off(event: string, fn: () => void) {}
/**
* 发布
* @param event 订阅事件
* @param arg 额外参数
* @returns
*/
emit(event: string, ...arg: any[]) {
}
eventEmitter.emit(this.key); // 触发面板的订阅事件
eventEmitter.emit(GLOBAL); // 触发顶级订阅事件,就包括右侧目录的更新
exportación de pdf/word
La exportación de PDF se implementa mediante html2Canvas + jsPDF. Cabe señalar que cuando la imagen es demasiado larga, el PDF segmentará la imagen y es posible que el área de contenido esté segmentada. Necesitamos calcular manualmente la altura del panel para ver si excede el documento actual. Si excede la altura, debemos dividirlo con anticipación y agregarlo a la página siguiente. Intente dividir el panel de directorio y el panel de datos juntos. cuanto más se pueda.
La exportación de Word se implementa mediante html-docx-js. Debe conservar la estructura del directorio y agregar un resumen debajo del panel. Esto requiere que conviertamos las imágenes para cada panel por separado.
La idea de implementación es atravesar paneles . Para encontrar el panel de directorio, insértelo usando las etiquetas h1 y h2. Si es un panel de datos, mantenga un atributo ref en el panel de datos, que nos permite obtener la información DOM. el panel actual y realice la conversión de imágenes basándose en este, y en formato base64 (Word solo admite la inserción de imágenes en base64).
escribe al final
La versión actual del informe de inspección aún está en su fase inicial y no está en su forma final. Con actualizaciones iterativas posteriores, agregaremos gradualmente múltiples funciones, incluidas explicaciones resumidas.
Después de implementarse de la manera actual, si es necesario ajustar la interfaz de la interfaz de usuario en el futuro, solo es necesario modificar los componentes de la interfaz de usuario relevantes de manera específica, como agregar gráficos circulares, tablas, etc. Para cambios en el nivel de interacción de datos, solo necesita ingresar DashboardModel y PanelModel para realizar las actualizaciones necesarias. Además, para escenarios específicos, también podemos extraer de manera flexible clases especiales para el procesamiento para garantizar que todo el proceso de iteración sea más modular y eficiente.
Dirección de descarga del "Informe técnico del producto Dutstack": https://www.dtstack.com/resources/1004?src=szsm
Dirección de descarga del "Libro técnico sobre prácticas de la industria de gobernanza de datos": https://www.dtstack.com/resources/1001?src=szsm
Para aquellos que quieran saber o consultar más sobre productos de big data, soluciones industriales y casos de clientes, visite el sitio web oficial de Kangaroo Cloud: https://www.dtstack.com/?src=szkyzg
Linus se encargó de evitar que los desarrolladores del kernel reemplazaran las pestañas con espacios. Su padre es uno de los pocos líderes que puede escribir código, su segundo hijo es el director del departamento de tecnología de código abierto y su hijo menor es un núcleo de código abierto. Colaborador Robin Li: El lenguaje natural se convertirá en un nuevo lenguaje de programación universal. El modelo de código abierto se quedará cada vez más atrás de Huawei: tomará 1 año migrar completamente 5,000 aplicaciones móviles de uso común a Hongmeng, que es el lenguaje más propenso. Vulnerabilidades de terceros. Se lanzó el editor de texto enriquecido Quill 2.0 con características, confiabilidad y experiencia de desarrolladores que Ma Huateng y Zhou Hongyi se dieron la mano para "eliminar los rencores". La fuente de Laoxiangji no es el código, las razones detrás de esto son muy conmovedoras. Google anunció una reestructuración a gran escala.