构建服务于Flutter与Angular微前端的Rails网关及其Oracle数据库适配层


我们面临的困境相当典型:一个仍在为公司创造核心价值,但技术栈已严重老化的系统。前端是 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)开发,并嵌入到现有页面中。

  • 优势:
    1. 技术异构: 新功能可以使用最适合的技术栈,我们初步选定 Flutter Web,看重其出色的渲染性能和未来潜在的跨端复用能力。
    2. 独立部署: 新模块可以独立开发、测试和部署,降低发布风险,加快迭代速度。
    3. 增量替换: 这是典型的“绞杀者模式”(Strangler Fig Pattern)。新功能逐步上线,最终可以蚕食并替换掉整个旧系统。
  • 劣势:
    1. 架构复杂性: 需要解决主应用与微应用之间的通信、状态共享、样式隔离和身份认证等一系列问题。
    2. 集成成本: 需要一个强大的中间层来协调前后端,避免让前端直接面对复杂的遗留系统。

最终,我们选择了方案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 中有三张核心表:PROJECTSKANBAN_COLUMNSKANBAN_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:处理复杂的状态变更

看板的拖拽操作对应着复杂的数据库更新。例如,将一张卡片从一个列表拖拽到另一个列表,可能需要:

  1. 更新卡片本身的 column_idposition
  2. 更新原列表中,在该卡片之后所有卡片的 position
  3. 更新新列表中,在该卡片之后所有卡片的 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_atinsert_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),并在后台启动数据同步任务,而前端应用对此完全无感。这个架构为我们赢得了宝贵的时间和灵活性。


  目录