我们面临的系统是一头沉睡的巨兽。一个运行了近十五年的核心资产管理平台,前端是早已无人维护的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组件实现了:
- 声明式API: 使用者只需提供
procedure-name和params。 - 内部状态管理: 通过
_state管理加载、成功、失败等状态,并渲染不同UI。 - 响应式更新: 当外部传入的
procedureName或params变化时,自动重新获取数据。 - 动态表格: 根据返回数据自动生成表头和内容。
- 完整分页: 包含客户端的分页控制和后端的全套分页逻辑。
- 错误处理: 清晰地展示错误状态,防止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的成功、失败和空数据返回,测试组件的各种渲染状态。
方案的局限性与未来展望
这个方案并非万能药。它非常适合于将遗留系统中以“报表展示”为主的查询类功能进行现代化改造。但它有明显的边界:
- 写操作复杂性: 对于需要复杂事务控制的写操作(插入、更新),这种简单的网关模式会变得力不从心。事务的边界难以在无状态的HTTP请求中清晰定义,这类功能仍然需要更厚重的后端服务层来封装业务逻辑。
- API网关的瓶颈: 随着被改造的模块增多,这个单体的Node.js网关可能会成为性能瓶颈或单点故障。未来的演进方向可以是将其拆分为多个无服务器函数(Serverless Functions),每个函数负责一类或一个存储过程的调用,利用云平台的弹性伸缩能力。
- 类型安全: 目前从Node.js到Lit组件的数据传递是基于运行时JSON的,类型安全依赖于手写的TypeScript接口。一个更先进的方案是探索从Oracle的包定义(Package Spec)自动生成TypeScript类型定义文件的工具链,实现从数据库到前端的端到端类型安全,这将极大提升大型项目的可维护性。