为大型Oracle遗留系统构建一个高性能Lit数据网格组件


我们面临的系统是一头沉睡的巨兽。一个运行了近十五年的核心资产管理平台,前端是早已无人维护的JSP,后端业务逻辑的命脉则深埋于数万行Oracle PL/SQL存储过程和包之中。全面重写的风险和成本都无法估量,业务方唯一的要求是:在不触碰核心数据库逻辑的前提下,逐步用现代化的Web技术替换掉那些迟钝、过时的用户界面。

最初的构想是在Java后端和Oracle之间加一个标准的Spring Boot微服务层,提供RESTful API。但这个方案很快被否决。为每一个陈旧的报表和数据录入界面都开发一套专用的CRUD API,工作量巨大,且增加了新的维护负担。我们需要的是一个更轻量、更通用的解决方案,一个能让前端开发者直接、安全地“对话”Oracle存储过程,并快速构建界面的工具。

最终的方案定格在一个两层结构上:一个极简的Node.js后端,其唯一职责是作为协议转换网关,将HTTP请求转化为对Oracle数据库的调用;以及一个基于Lit构建的、高度可复用的数据网关组件(Data Gateway Component),它封装了所有数据请求、状态管理、分页和渲染逻辑。这个组件将成为我们前端现代化的核心库基石。

graph TD
    A[浏览器: Lit 数据网格组件] -- HTTP/JSON 请求 --> B(Node.js API 网关);
    B -- oracledb 驱动 --> C((Oracle 数据库));
    C -- 游标/结果集 --> B;
    B -- HTTP/JSON 响应 --> A;
    subgraph 遗留系统核心
        C
    end
    subgraph 现代化改造层
        B
        A
    end

第一步:构建坚固的Node.js网关

这个网关必须做到极致的轻薄和稳定。它的生命周期里只做一件事:接收请求,调用指定的存储过程,然后将结果集(通常是Oracle的REF CURSOR)转换成JSON格式返回。在真实项目中,配置和连接池管理是稳定性的关键。

项目结构:

/oracle-api-gateway
|-- /config
|   |-- db.config.js
|   |-- server.config.js
|-- /src
|   |-- services
|   |   |-- oracle.service.js
|   |-- controllers
|   |   |-- data.controller.js
|   |-- routes
|   |   |-- index.js
|-- .env
|-- server.js

数据库配置 (/config/db.config.js):
这里的坑在于,绝不能将密码硬编码。使用环境变量,并为连接池设置合理的参数,避免在高并发下耗尽数据库会话。

// /config/db.config.js
require('dotenv').config();

module.exports = {
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  connectString: process.env.DB_CONNECTION_STRING,
  poolMin: 4,
  poolMax: 20,
  poolIncrement: 2,
  poolTimeout: 60, // 60秒后获取不到连接则超时
};

Oracle服务核心逻辑 (/src/services/oracle.service.js):
这是整个后端的关键。我们使用node-oracledb库。核心函数executeProcedure必须是通用的,能够处理不同的存储过程、输入参数和分页逻辑。一个常见的错误是忽略了对REF CURSOR这种Oracle特有类型的处理。

// /src/services/oracle.service.js
const oracledb = require('oracledb');
const dbConfig = require('../../config/db.config');

let pool;

// 初始化连接池
async function initialize() {
  try {
    pool = await oracledb.createPool(dbConfig);
    console.log('Oracle connection pool started');
  } catch (err) {
    console.error('Error starting Oracle connection pool:', err);
    process.exit(1); // 启动失败直接退出
  }
}

// 关闭连接池,用于优雅停机
async function close() {
  if (pool) {
    await pool.close();
    console.log('Oracle connection pool closed');
  }
}

// 核心执行函数
async function executeProcedure(procedureName, params, page = 1, pageSize = 50) {
  let connection;
  try {
    connection = await pool.getConnection();

    // 动态构建绑定参数,这是通用性的关键
    // 假设所有输入参数都是字符串或数字,输出是一个游标
    const bindParams = {
      ...params,
      p_offset: (page - 1) * pageSize,
      p_limit: pageSize,
      p_result_cursor: { type: oracledb.CURSOR, dir: oracledb.BIND_OUT }
    };

    // 动态构建PL/SQL调用块
    const paramNames = Object.keys(params).map(p => `${p} => :${p}`).join(', ');
    const sql = `BEGIN ${procedureName}(${paramNames}, p_offset => :p_offset, p_limit => :p_limit, p_result_cursor => :p_result_cursor); END;`;
    
    const result = await connection.execute(sql, bindParams);
    const resultSet = result.outBinds.p_result_cursor;

    // 从游标中获取所有行
    const rows = await resultSet.getRows(); 
    
    // 千万不要忘记关闭游标,否则会造成严重的资源泄露
    await resultSet.close();

    // 获取总记录数,这对于前端分页至关重要
    // 我们约定,每个分页存储过程都有一个配套的 _COUNT 函数
    const countResult = await connection.execute(
        `SELECT ${procedureName}_COUNT(${paramNames}) as total FROM DUAL`,
        params
    );
    const totalRecords = countResult.rows[0].TOTAL;

    return {
      data: rows.map(row => {
        // oracledb默认返回数组,需要转换为对象
        const obj = {};
        resultSet.metaData.forEach((meta, i) => {
          obj[meta.name.toLowerCase()] = row[i];
        });
        return obj;
      }),
      pagination: {
        currentPage: page,
        pageSize: pageSize,
        totalRecords: totalRecords,
        totalPages: Math.ceil(totalRecords / pageSize),
      }
    };

  } catch (err) {
    console.error(`Error executing procedure ${procedureName}:`, err);
    // 向上抛出特定类型的错误,方便controller层处理
    throw new Error('Database execution failed'); 
  } finally {
    if (connection) {
      try {
        await connection.close(); // 释放连接回池
      } catch (err) {
        console.error('Error closing connection:', err);
      }
    }
  }
}

module.exports = { initialize, close, executeProcedure };

这个服务层考虑了连接池、动态参数绑定、游标处理和分页元数据,是生产级代码的基础。

控制器和路由 (/src/controllers/data.controller.js):

// /src/controllers/data.controller.js
const oracleService = require('../services/oracle.service');

async function getData(req, res) {
  const { procedureName } = req.params;
  const { page = 1, pageSize = 50, ...queryParams } = req.query;

  // 在真实项目中,这里需要一个白名单来验证procedureName的合法性,防止SQL注入风险
  const ALLOWED_PROCEDURES = ['PKG_ASSET.GET_ASSET_LIST', 'PKG_USER.GET_USER_REPORTS'];
  if (!ALLOWED_PROCEDURES.includes(procedureName)) {
      return res.status(403).json({ error: 'Procedure not allowed' });
  }

  try {
    const result = await oracleService.executeProcedure(
      procedureName,
      queryParams,
      parseInt(page, 10),
      parseInt(pageSize, 10)
    );
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: 'An internal server error occurred.' });
  }
}

module.exports = { getData };

第二步:打造自包含的Lit数据网格组件

选择Lit的核心原因是它的轻量和标准性。它生成的Web Components可以无缝嵌入到任何页面(包括旧的JSP页面),没有框架依赖,并且拥有优秀的性能。我们的目标是创建一个<oracle-data-grid>组件,通过属性(properties)接收存储过程名称和参数,内部完成所有工作。

组件定义 (/components/oracle-data-grid.ts):

// /components/oracle-data-grid.ts
import { LitElement, html, css, PropertyValueMap } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';

// 定义组件内部的状态机
type FetchState = 'idle' | 'loading' | 'success' | 'error';

// 定义API返回的数据结构,TypeScript是保证代码健壮性的关键
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    currentPage: number;
    pageSize: number;
    totalRecords: number;
    totalPages: number;
  };
}

@customElement('oracle-data-grid')
export class OracleDataGrid extends LitElement {
  // --- CSS样式 ---
  static styles = css`
    :host {
      display: block;
      font-family: sans-serif;
      border: 1px solid #ccc;
      border-radius: 4px;
    }
    .grid-container {
      overflow-x: auto;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      min-width: 600px;
    }
    th, td {
      padding: 12px 15px;
      text-align: left;
      border-bottom: 1px solid #ddd;
    }
    th {
      background-color: #f4f4f4;
      font-weight: bold;
    }
    .status-container {
      padding: 50px;
      text-align: center;
      color: #888;
    }
    .pagination {
        padding: 15px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        border-top: 1px solid #ccc;
    }
    button {
        padding: 8px 12px;
        cursor: pointer;
    }
  `;

  // --- 公开属性 (Public Properties) ---
  @property({ type: String, attribute: 'api-base-url' })
  apiBaseUrl = '/api/data';

  @property({ type: String, attribute: 'procedure-name' })
  procedureName?: string;

  @property({ type: Object })
  params: Record<string, string | number> = {};

  // --- 内部状态 (Internal State) ---
  @state()
  private _state: FetchState = 'idle';

  @state()
  private _data: Record<string, any>[] = [];
  
  @state()
  private _columns: string[] = [];

  @state()
  private _pagination = { currentPage: 1, totalPages: 1, totalRecords: 0 };

  @state()
  private _error: string | null = null;
  
  // --- 生命周期回调 ---
  protected updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
    // 当关键属性变化时,重新获取数据
    if (changedProperties.has('procedureName') || changedProperties.has('params')) {
      if (this.procedureName) {
        this._fetchData();
      }
    }
  }

  // --- 数据获取逻辑 ---
  private async _fetchData(page = 1) {
    if (!this.procedureName) return;

    this._state = 'loading';
    this._error = null;

    const queryParams = new URLSearchParams({
      page: String(page),
      ...this.params,
    });

    try {
      const response = await fetch(`${this.apiBaseUrl}/${this.procedureName}?${queryParams}`);

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result: PaginatedResponse<Record<string, any>> = await response.json();
      
      this._data = result.data;
      if (result.data.length > 0) {
        // 动态生成列标题
        this._columns = Object.keys(result.data[0]);
      } else {
        this._columns = [];
      }
      this._pagination = result.pagination;
      this._state = 'success';

    } catch (e) {
      this._state = 'error';
      this._error = (e instanceof Error) ? e.message : 'An unknown error occurred';
      console.error('Failed to fetch data:', e);
    }
  }

  // --- 分页事件处理 ---
  private _handlePrevPage() {
    if (this._pagination.currentPage > 1) {
        this._fetchData(this._pagination.currentPage - 1);
    }
  }

  private _handleNextPage() {
    if (this._pagination.currentPage < this._pagination.totalPages) {
        this._fetchData(this._pagination.currentPage + 1);
    }
  }

  // --- 渲染逻辑 ---
  private _renderContent() {
    switch (this._state) {
      case 'loading':
        return html`<div class="status-container">Loading...</div>`;
      case 'error':
        return html`<div class="status-container">Error: ${this._error}</div>`;
      case 'success':
        if (this._data.length === 0) {
            return html`<div class="status-container">No data found.</div>`;
        }
        return html`
            <div class="grid-container">
                <table>
                    <thead>
                    <tr>
                        ${this._columns.map(col => html`<th>${col.replace(/_/g, ' ').toUpperCase()}</th>`)}
                    </tr>
                    </thead>
                    <tbody>
                    ${repeat(this._data, (row) => row.id, (row) => html`
                        <tr>
                        ${this._columns.map(col => html`<td>${row[col]}</td>`)}
                        </tr>
                    `)}
                    </tbody>
                </table>
            </div>
        `;
      default:
        return html`<div class="status-container">Please provide a procedure name to fetch data.</div>`;
    }
  }
  
  private _renderPagination() {
    if (this._state !== 'success' || this._data.length === 0) {
      return '';
    }
    
    return html`
      <div class="pagination">
        <div>
            <span>Page ${this._pagination.currentPage} of ${this._pagination.totalPages}</span>
            <span style="margin-left: 20px;">Total: ${this._pagination.totalRecords} records</span>
        </div>
        <div>
            <button @click=${this._handlePrevPage} .disabled=${this._pagination.currentPage <= 1}>Previous</button>
            <button @click=${this._handleNextPage} .disabled=${this._pagination.currentPage >= this._pagination.totalPages}>Next</button>
        </div>
      </div>
    `;
  }

  render() {
    return html`
      ${this._renderContent()}
      ${this._renderPagination()}
    `;
  }
}

这个Lit组件实现了:

  1. 声明式API: 使用者只需提供procedure-nameparams
  2. 内部状态管理: 通过_state管理加载、成功、失败等状态,并渲染不同UI。
  3. 响应式更新: 当外部传入的procedureNameparams变化时,自动重新获取数据。
  4. 动态表格: 根据返回数据自动生成表头和内容。
  5. 完整分页: 包含客户端的分页控制和后端的全套分页逻辑。
  6. 错误处理: 清晰地展示错误状态,防止UI崩溃。

现在,任何前端开发者,无论他是否懂Oracle,都可以在页面中像这样使用它:

<oracle-data-grid
  procedure-name="PKG_ASSET.GET_ASSET_LIST"
  .params=${{ asset_type: 'SERVER', status: 'ACTIVE' }}
></oracle-data-grid>

.params前的点是Lit的属性绑定语法,用于传递对象。

单元测试思路

  • Node.js API: 使用Supertest发起HTTP请求,并用Jest Mock oracle.service.js。测试用例应覆盖:成功调用、procedure白名单拦截、数据库执行失败返回500、分页参数正确传递等场景。
  • Lit Component: 使用@web/test-runner。通过设置组件的属性,断言其内部状态和渲染出的DOM是否符合预期。使用msw (Mock Service Worker) 拦截fetch请求,模拟API的成功、失败和空数据返回,测试组件的各种渲染状态。

方案的局限性与未来展望

这个方案并非万能药。它非常适合于将遗留系统中以“报表展示”为主的查询类功能进行现代化改造。但它有明显的边界:

  1. 写操作复杂性: 对于需要复杂事务控制的写操作(插入、更新),这种简单的网关模式会变得力不从心。事务的边界难以在无状态的HTTP请求中清晰定义,这类功能仍然需要更厚重的后端服务层来封装业务逻辑。
  2. API网关的瓶颈: 随着被改造的模块增多,这个单体的Node.js网关可能会成为性能瓶颈或单点故障。未来的演进方向可以是将其拆分为多个无服务器函数(Serverless Functions),每个函数负责一类或一个存储过程的调用,利用云平台的弹性伸缩能力。
  3. 类型安全: 目前从Node.js到Lit组件的数据传递是基于运行时JSON的,类型安全依赖于手写的TypeScript接口。一个更先进的方案是探索从Oracle的包定义(Package Spec)自动生成TypeScript类型定义文件的工具链,实现从数据库到前端的端到端类型安全,这将极大提升大型项目的可维护性。

  目录