我们面临的困境相当典型:一个仍在为公司创造核心价值,但技术栈已严重老化的系统。前端是 Angular.js 1.5,后端是庞大的 Ruby on Rails 3 单体应用,数据库则牢牢地绑定在 Oracle 11g 上。业务部门要求为这个系统增加一个高度动态、性能要求严苛的实时看板(Kanban)功能模块,用户期望能获得类似 Trello 或 Jira 的流畅拖拽体验。直接在 Angular.js 上实现这种复杂交互,性能和开发体验都将是灾难。
一个全盘重写的方案在会议上被迅速否决。原因很现实:风险、时间和成本都无法接受,业务无法承受长达18个月以上的功能冻结期。这迫使我们必须寻找一条渐进式的现代化改造路径。
方案A:升级现有技术栈
初步的想法是原地升级。将 Angular.js 升级到现代 Angular,Rails 3 升级到 Rails 7。
- 优势: 理论上可以保留现有业务逻辑和数据模型,风险看似可控。
- 劣势: 实践中,从 Angular.js 到 Angular 的迁移无异于重写。Rails 3 到 7 跨越了多个大版本,大量的 Gem 依赖、API 变更和架构模式(例如从 Asset Pipeline 到 Webpacker/Vite)的迁移工作量同样巨大。最关键的是,这个方案并没有解决核心问题——为新功能引入更合适的技术栈。我们最终得到的可能只是一个“新瓶装旧酒”的系统,依然无法满足新看板模块对性能和交互的要求。
方案B:微前端架构与绞杀者模式
第二个方案更为激进,也更具挑战性:采用微前端架构。维持现有 Angular.js 应用作为主容器(Host Application),将新的看板功能作为一个独立的微应用(Micro App)开发,并嵌入到现有页面中。
- 优势:
- 技术异构: 新功能可以使用最适合的技术栈,我们初步选定 Flutter Web,看重其出色的渲染性能和未来潜在的跨端复用能力。
- 独立部署: 新模块可以独立开发、测试和部署,降低发布风险,加快迭代速度。
- 增量替换: 这是典型的“绞杀者模式”(Strangler Fig Pattern)。新功能逐步上线,最终可以蚕食并替换掉整个旧系统。
- 劣势:
- 架构复杂性: 需要解决主应用与微应用之间的通信、状态共享、样式隔离和身份认证等一系列问题。
- 集成成本: 需要一个强大的中间层来协调前后端,避免让前端直接面对复杂的遗留系统。
最终,我们选择了方案B。这是一个着眼于未来的架构决策,尽管短期内会引入复杂性,但它为系统的长期演进铺平了道路。我们的整体架构设计如下:
graph TD subgraph Browser A[Angular.js Host App] B(Flutter Web Micro App) end subgraph Backend C[Rails BFF Gateway] D[Legacy Oracle 11g] end A -- "Loads & Mounts" --> B A -- "GraphQL API Calls" --> C B -- "GraphQL API Calls" --> C C -- "OCI8 Adapter" --> D style B fill:#0277bd,stroke:#01579b,stroke-width:2px,color:#fff style C fill:#cc0000,stroke:#990000,stroke-width:2px,color:#fff
Rails 应用的角色从一个传统的 MVC 单体,转变为一个服务于前端的后端(Backend-for-Frontend, BFF)。它将作为 API 网关,使用 GraphQL 为 Angular 和 Flutter 提供统一的数据接口,同时内部封装与 Oracle 数据库交互的所有复杂逻辑和适配工作。
核心实现:Rails BFF 网关与 Oracle 适配
这是整个方案的技术核心。BFF 的主要职责是“翻译”,将现代前端的需求(GraphQL)转换为对遗留数据库的查询(SQL),并处理掉所有脏活累活。
1. 环境配置与数据库连接
首先是连接 Oracle。在生产环境中,这通常是最痛苦的一步。我们需要 ruby-oci8
gem,并正确配置环境变量。
Gemfile
:
# Gemfile
# ...
gem 'graphql'
gem 'ruby-oci8'
# ...
config/database.yml
:
# config/database.yml
production:
adapter: oracle_enhanced
database: '(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=your-oracle-host)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=your_service_name)))'
username: your_username
password: <%= ENV['ORACLE_DATABASE_PASSWORD'] %>
encoding: 'UTF8' # 关键:确保与数据库编码一致,避免乱码
pool: 25 # 根据并发量精细调整
connect_timeout: 10
read_timeout: 30
一个常见的坑在于环境变量 NLS_LANG
。如果应用服务器的字符集与 Oracle 数据库不匹配,会导致严重的乱码问题。必须在 Rails 应用的启动脚本或 Dockerfile 中显式设置它,例如:
# Dockerfile or entrypoint.sh
ENV NLS_LANG=AMERICAN_AMERICA.AL32UTF8
2. 构建 GraphQL Schema
我们使用 graphql-ruby
来构建 API。目标是为看板设计一个清晰、强类型的 Schema,屏蔽掉底层 Oracle 表结构的混乱。
假设 Oracle 中有三张核心表:PROJECTS
、KANBAN_COLUMNS
和 KANBAN_CARDS
,它们之间通过外键关联。
# app/graphql/types/kanban_card_type.rb
module Types
class KanbanCardType < Types::BaseObject
description "A single card on the Kanban board"
field :id, ID, null: false
field :title, String, null: false
field :content, String, null: true
field :position, Integer, null: false
field :assignee, Types::UserType, null: true # 假设有关联的 UserType
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
# 这里的 resolver 模拟了从一个可能的关联模型加载数据
def assignee
# 在真实项目中,这里会使用 Dataloader 来避免 N+1 查询
# ::User.find_by(id: object.assignee_id)
# 模拟数据
{ id: object.assignee_id, name: "User #{object.assignee_id}" } if object.assignee_id
end
end
end
# app/graphql/types/kanban_column_type.rb
module Types
class KanbanColumnType < Types::BaseObject
description "A column on the Kanban board"
field :id, ID, null: false
field :title, String, null: false
field :position, Integer, null: false
field :cards, [Types::KanbanCardType], null: false
def cards
# 同样,在生产代码中,必须使用 Dataloader
# ::KanbanCard.where(column_id: object.id).order(:position)
object.cards.order(:position) # 假设 Rails 模型关系已建立
end
end
end
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
field :kanban_board, Types::KanbanBoardType, null: true do
description "Find a Kanban board by project ID"
argument :project_id, ID, required: true
end
def kanban_board(project_id:)
# 这里的 Project 是 ActiveRecord 模型,它背后是 Oracle 的 PROJECTS 表
project = ::Project.find_by(id: project_id)
# 权限校验
raise GraphQL::ExecutionError, "Permission denied or project not found" unless context[:current_user].can_view?(project)
project
rescue ActiveRecord::RecordNotFound
raise GraphQL::ExecutionError, "Project with ID #{project_id} not found"
rescue OCIError => e
# 捕获 Oracle 特定的底层错误,并进行日志记录和包装
Rails.logger.error("Oracle DB Error fetching kanban_board: #{e.message}")
raise GraphQL::ExecutionError, "A database error occurred. Please contact support."
end
end
end
# app/graphql/types/kanban_board_type.rb
# ... 这是一个代表整个看板的对象,它可能直接对应于 Project
module Types
class KanbanBoardType < Types::BaseObject
field :id, ID, null: false
field :project_name, String, null: false
field :columns, [Types::KanbanColumnType], null: false
def project_name
object.name # Project 模型的 name 字段
end
def columns
# 使用 Dataloader 优化 N+1 查询
dataloader.with(Sources::KanbanColumnsByProject).load(object.id)
end
end
end
3. 解决 N+1 查询与性能问题
直接在 Resolver 中进行数据库查询会导致严重的 N+1 问题。graphql-batch
(或 Dataloader) 是解决此问题的标准实践。我们需要为每个关联关系创建一个 Source。
# app/graphql/sources/kanban_columns_by_project.rb
class Sources::KanbanColumnsByProject < GraphQL::Dataloader::Source
def fetch(project_ids)
# 一次性加载所有项目的所有列
columns_by_project_id = ::KanbanColumn.where(project_id: project_ids).order(:position).group_by(&:project_id)
# 返回与输入 project_ids 顺序一致的结果
project_ids.map { |id| columns_by_project_id[id] || [] }
end
end
通过这种方式,无论前端请求多少个看板,我们都只需要固定次数的 SQL 查询来获取所有列和卡片,而不是为每个看板单独查询。这对于应对 Oracle 这种传统关系型数据库的查询开销至关重要。
4. Mutation:处理复杂的状态变更
看板的拖拽操作对应着复杂的数据库更新。例如,将一张卡片从一个列表拖拽到另一个列表,可能需要:
- 更新卡片本身的
column_id
和position
。 - 更新原列表中,在该卡片之后所有卡片的
position
。 - 更新新列表中,在该卡片之后所有卡片的
position
。
这些操作必须在一个数据库事务中完成,以保证数据一致性。GraphQL Mutation 是实现这一点的理想位置。
# app/graphql/mutations/move_kanban_card.rb
module Mutations
class MoveKanbanCard < BaseMutation
argument :card_id, ID, required: true
argument :new_column_id, ID, required: true
argument :new_position, Integer, required: true
field :card, Types::KanbanCardType, null: true
field :errors, [String], null: false
def resolve(card_id:, new_column_id:, new_position:)
# 单元测试思路:
# 1. Mock Card, Column 模型和 ActiveRecord::Base.transaction
# 2. 验证在事务块内,是否正确调用了 update_positions_in_column 等方法
# 3. 验证当任何一步失败时,事务是否回滚
# 4. 验证权限检查逻辑是否生效
card = ::KanbanCard.find(card_id)
original_column_id = card.column_id
# 权限校验
# ...
begin
::KanbanCard.transaction do
# 1. 从原列中移除
::KanbanColumn.find(original_column_id).remove_card_at(card.position)
# 2. 插入到新列
::KanbanColumn.find(new_column_id).insert_card_at(card, new_position)
# 3. 更新卡片自身属性
card.update!(column_id: new_column_id, position: new_position)
end
{ card: card.reload, errors: [] }
rescue ActiveRecord::RecordInvalid => e
{ card: nil, errors: e.record.errors.full_messages }
rescue StandardError => e
Rails.logger.error("Failed to move card #{card_id}: #{e.message}")
{ card: nil, errors: ["An unexpected error occurred."] }
end
end
end
end
这里的 remove_card_at
和 insert_card_at
是封装在 KanbanColumn
模型中的业务逻辑,它们负责处理列内其他卡片位置的重排,这比在 Mutation 中写一堆裸 SQL 要清晰和可维护得多。
Flutter 微前端的实现与集成
Flutter Web 应用本身是一个标准的 Flutter 项目,专注于看板 UI 的构建。
1. Flutter 项目结构与 GraphQL 集成
我们使用 graphql_flutter
包来与 Rails BFF 通信。
// lib/main.dart
// ... imports
import 'package:graphql_flutter/graphql_flutter.dart';
// ...
// 配置 GraphQLClient
final HttpLink httpLink = HttpLink('https://your-rails-bff.com/graphql');
// 从主应用获取认证 Token
final String? authToken = getAuthTokenFromHost(); // 这是需要实现的与宿主通信的逻辑
final AuthLink authLink = AuthLink(
getToken: () async => 'Bearer $authToken',
);
final Link link = authLink.concat(httpLink);
final ValueNotifier<GraphQLClient> client = ValueNotifier(
GraphQLClient(
cache: GraphQLCache(store: InMemoryStore()),
link: link,
),
);
// ... Widget build
return GraphQLProvider(
client: client,
child: MaterialApp(
// ...
),
);
2. 加载与通信
最棘手的部分是如何将 Flutter Web App 集成到 Angular.js 宿主中,并进行通信。我们放弃了 IFrame 的方案,因为它会带来额外的样式和交互问题。我们选择了一种更现代的方式:将 Flutter Web App 作为一个 JavaScript 模块加载。
构建 Flutter Web App:
执行 flutter build web --web-renderer canvaskit
会在 build/web
目录下生成 main.dart.js
和其他资源。我们将这些静态资源部署到 CDN 或 Rails 自己的 public
目录下。
在 Angular.js 中加载:
Angular.js 的一个 controller 负责动态创建一个 div
容器,并加载 Flutter 的启动脚本。
// angular_host_controller.js
(function() {
'use strict';
angular
.module('legacyApp')
.controller('KanbanViewController', KanbanViewController);
KanbanViewController.$inject = ['$scope', '$element', 'AuthService'];
function KanbanViewController($scope, $element, AuthService) {
var vm = this;
vm.$onInit = function() {
// 1. 创建挂载点
var flutterRoot = document.createElement('div');
flutterRoot.id = 'flutter-kanban-root';
$element[0].appendChild(flutterRoot);
// 2. 将认证信息和配置信息暴露给 Flutter
// 这是关键的通信握手
window._flutterKanbanConfig = {
authToken: AuthService.getToken(),
projectId: $scope.projectId, // 从 scope 获取项目ID
apiEndpoint: '/graphql'
};
// 3. 动态加载 Flutter 启动脚本
var flutterScript = document.createElement('script');
flutterScript.src = '/assets/flutter_kanban/main.dart.js'; // 假设资源部署在这里
flutterScript.defer = true;
document.body.appendChild(flutterScript);
};
vm.$onDestroy = function() {
// 在 Angular scope 销毁时,需要有机制通知 Flutter app 进行清理
// 这可以通过 CustomEvent 实现
window.dispatchEvent(new CustomEvent('unmountFlutterKanban'));
};
}
})();
在 Flutter 侧,我们需要在 main.dart
中读取这些由宿主注入的全局配置。
// lib/utils/host_bridge.dart
import 'dart:js' as js;
String? getAuthTokenFromHost() {
// 通过 js interop 读取挂载在 window 上的配置
if (js.context.hasProperty('_flutterKanbanConfig')) {
final config = js.context['_flutterKanbanConfig'];
if (config != null && js.JsObject.fromBrowserObject(config).hasProperty('authToken')) {
return js.JsObject.fromBrowserObject(config)['authToken'];
}
}
// 在开发环境或独立运行时提供 fallback
return 'fallback_dev_token';
}
// ... 同样的方式获取 projectId 等
架构的局限性与未来展望
这套方案并非银弹。它成功地让我们在不中断业务的前提下,为遗留系统引入了现代技术,快速交付了核心功能。但其固有的复杂性也带来了新的挑战。
首先,Oracle 数据库依然是整个系统的性能瓶颈和维护难点。BFF 层虽然做了适配和优化,但无法改变底层数据模型的局限性。任何复杂的查询都需要在 Rails 层 carefully crafting,避免 ruby-oci8
的性能陷阱。
其次,微前端的运维成本不低。我们需要维护两套前端构建流程,处理它们之间的版本兼容性问题。跨应用的调试,尤其是在处理认证和状态同步的边界场景时,会变得异常困难。
未来的路径是明确的:继续以看板模块为起点,用 Flutter 或其他现代框架逐步开发新功能,替换掉旧的 Angular.js 页面。每替换一个模块,我们就向着最终完全移除旧技术栈迈进一步。同时,BFF 层也为未来的数据迁移做好了准备。当条件成熟时,我们可以将 BFF 连接到一个新的数据库(如 PostgreSQL),并在后台启动数据同步任务,而前端应用对此完全无感。这个架构为我们赢得了宝贵的时间和灵活性。