构建服务于 Gatsby 静态站点与原生 iOS 客户端的 Quarkus 统一 API


团队扩张带来的第一个信号往往不是沟通成本的增加,而是工具链的断裂。在我们团队,这个断裂点出现在CI/CD状态、功能开关(Feature Flag)配置和内部技术文档这三个原本独立的领域。信息散落在Jenkins、ConfigCat和Confluence中,开发者需要打开无数个标签页才能拼凑出一个功能的完整生命周期。在一次回顾会议(Retrospective)中,我们决定用一个Sprint周期来验证一个想法:构建一个统一的内部开发者平台(Internal Developer Platform - IDP),作为所有工程信息的唯一入口。

技术选型过程充满了现实主义的权衡。后端,我们是Java技术栈,但又不想为内部工具付出传统Spring Boot应用那样的资源开销和启动时间,Quarkus的快速启动和低内存占用特性使其成为首选。前端门户,我们需要极致的访问速度和零服务器运维成本,Gatsby的静态站点生成(SSG)模式完美契合,它能在构建时从数据源拉取信息生成纯HTML/CSS/JS,这对文档和仪表盘类应用是天作之合。

最具争议的是移动端。有人提议PWA,但我们的核心诉求是为SRE和团队负责人提供一个能随时随地处理紧急事务的工具,比如一键回滚部署、关闭高危功能开关。这意味着我们需要稳定的系统级推送通知、可靠的离线操作和生物识别级别的安全性。这些是原生iOS应用最擅长的领域。

于是,这个由Quarkus、Gatsby和Swift(iOS)构成的异构技术栈诞生了。整个开发过程遵循Scrum框架,小步快跑,每个Sprint都产出可交付的增量。

Sprint 1: API 基石与核心模型定义 (Quarkus)

第一个Sprint的目标是搭建API服务器,并定义出最核心的数据模型:ProjectDeploymentFeatureFlag。我们使用Quarkus的RESTeasy Reactive和Hibernate Panache扩展来快速实现RESTful API。

这是FeatureFlag的实体定义,真实项目中,它远比一个布尔开关复杂,需要包含环境、灰度策略、创建者等元数据。

// src/main/java/org/acme/idp/models/FeatureFlag.java

package org.acme.idp.models;

import io.quarkus.hibernate.reactive.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import java.time.Instant;

@Entity
public class FeatureFlag extends PanacheEntity {

    @Column(nullable = false, unique = true)
    public String key;

    @Column(nullable = false)
    public String description;

    @Column(nullable = false)
    public boolean enabled;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    public Environment targetEnvironment;

    public String createdBy;
    
    public Instant createdAt;
    
    public Instant updatedAt;

    public static enum Environment {
        DEV,
        STAGING,
        PROD
    }

    // Panache 为我们自动处理了 getter/setter 和基础的 repository 方法
    // 在真实项目中,这里还会有更复杂的逻辑,比如版本控制、审批流状态等
}

对应的JAX-RS资源类,我们不仅要提供CRUD,更要考虑日志记录、异常处理和配置注入。这里的代码是生产级的。

// src/main/java/org/acme/idp/resources/FeatureFlagResource.java

package org.acme.idp.resources;

import io.quarkus.hibernate.reactive.panache.Panache;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.acme.idp.models.FeatureFlag;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;

import java.net.URI;
import java.time.Instant;
import java.util.List;

@Path("/api/v1/feature-flags")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FeatureFlagResource {

    @Inject
    Logger log;

    @ConfigProperty(name = "idp.api.default-user", defaultValue = "system")
    String defaultUser;

    @GET
    public Uni<List<FeatureFlag>> getAll() {
        log.info("Fetching all feature flags");
        return FeatureFlag.listAll();
    }

    @GET
    @Path("/{key}")
    public Uni<FeatureFlag> getByKey(@PathParam("key") String key) {
        log.infov("Fetching feature flag by key: {0}", key);
        return FeatureFlag.<FeatureFlag>find("key", key).firstResult()
                .onItem().ifNull().failWith(() -> {
                    log.warnv("Feature flag not found: {0}", key);
                    return new WebApplicationException("Feature flag with key " + key + " not found.", Response.Status.NOT_FOUND);
                });
    }

    @POST
    public Uni<Response> create(FeatureFlag flag) {
        log.infov("Attempting to create feature flag with key: {0}", flag.key);
        flag.createdAt = Instant.now();
        flag.updatedAt = Instant.now();
        if (flag.createdBy == null) {
            flag.createdBy = defaultUser;
        }

        // 使用 Panache 的事务功能确保原子性
        return Panache.withTransaction(flag::persist)
                .replaceWith(Response.created(URI.create("/api/v1/feature-flags/" + flag.key)).entity(flag).build())
                .onFailure().invoke(e -> log.error("Failed to create feature flag", e));
    }

    @PUT
    @Path("/{key}")
    public Uni<FeatureFlag> update(@PathParam("key") String key, FeatureFlag updatedFlag) {
        log.infov("Updating feature flag: {0}", key);
        return Panache.withTransaction(() -> 
            FeatureFlag.<FeatureFlag>find("key", key).firstResult()
                .onItem().ifNull().failWith(() -> new WebApplicationException("Flag not found", Response.Status.NOT_FOUND))
                .onItem().ifNotNull().transform(flagToUpdate -> {
                    flagToUpdate.enabled = updatedFlag.enabled;
                    flagToUpdate.description = updatedFlag.description;
                    flagToUpdate.targetEnvironment = updatedFlag.targetEnvironment;
                    flagToUpdate.updatedAt = Instant.now();
                    return flagToUpdate;
                })
        ).onFailure().recoverWithItem(err -> {
            log.errorv(err, "Update failed for key {0}", key);
            return null;
        });
    }
}

单元测试的思路是使用@QuarkusTest注解,配合RestAssured库来验证HTTP端点的行为,包括成功路径、404未找到和400错误请求。

Sprint 2: 静态门户的构建时数据集成 (Gatsby)

下一个Sprint,我们开始构建Web门户。Gatsby的核心优势是在gatsby-node.js中通过其数据层(GraphQL)在构建时拉取所有需要的数据。这意味着门户网站的性能与后端API的实时响应时间完全解耦。

我们首先需要一个数据源插件来从Quarkus API拉取数据。虽然可以用gatsby-source-graphql,但为了更好地控制数据转换和缓存,我们选择gatsby-source-rest-api,并进行简单配置。

// gatsby-config.js

module.exports = {
  plugins: [
    // ...其他插件
    {
      resolve: `gatsby-source-rest-api`,
      options: {
        name: `idp-api`,
        endpoints: [
          `${process.env.IDP_API_URL}/api/v1/projects`,
          `${process.env.IDP_API_URL}/api/v1/feature-flags`,
          `${process.env.IDP_API_URL}/api/v1/deployments`,
        ],
      },
    },
  ],
}

有了数据源,我们可以在任何页面组件中通过GraphQL查询数据。下面是一个展示所有功能开关的页面组件。

// src/pages/feature-flags.js

import React from 'react';
import { graphql }. from 'gatsby';

const FeatureFlagsPage = ({ data }) => {
  const flags = data.allIdpApiFeatureFlags.nodes;

  return (
    <main>
      <h1>Feature Flags</h1>
      <table>
        <thead>
          <tr>
            <th>Key</th>
            <th>Description</th>
            <th>Environment</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody>
          {flags.map(flag => (
            <tr key={flag.key}>
              <td><code>{flag.key}</code></td>
              <td>{flag.description}</td>
              <td>{flag.targetEnvironment}</td>
              <td>{flag.enabled ? '✅ Enabled' : '❌ Disabled'}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
};

// 这里的查询会在 `gatsby build` 阶段执行
// Gatsby 会请求 Quarkus API,并将结果注入到组件的 `data` prop 中
export const query = graphql`
  query {
    allIdpApiFeatureFlags(sort: { key: ASC }) {
      nodes {
        key
        description
        enabled
        targetEnvironment
        updatedAt
      }
    }
  }
`;

export default FeatureFlagsPage;

整个流程可以用下面的图来表示:

sequenceDiagram
    participant Dev as Developer
    participant Git
    participant CI as CI/CD Pipeline
    participant Quarkus as Quarkus API
    participant Gatsby as Gatsby Build
    participant CDN

    Dev->>Git: git push
    Git->>CI: Trigger Webhook
    CI->>Gatsby: Start `gatsby build`
    Gatsby->>Quarkus: Fetch data (GET /api/v1/feature-flags)
    Quarkus-->>Gatsby: Return JSON data
    Gatsby->>Gatsby: Generate static HTML/JS/CSS files
    CI->>CDN: Deploy static files

这个架构的优点是显而易见的:极快的加载速度、高可用性(只要CDN不出问题)和安全性(无直接数据库连接)。但缺点也同样突出:数据是构建时的快照,任何API数据的变更都需要重新触发构建和部署才能在门户上看到。

Sprint 3: 原生客户端的动态交互 (iOS/Swift)

当Web门户还在享受静态化的安逸时,iOS客户端则必须直面动态世界的复杂性。它的核心任务是实时展示数据并执行写操作(如更新功能开关)。

我们为iOS应用设计了一个简单的网络层,使用Swift的Codable协议来处理JSON序列化和反序列化,并用async/await来处理异步网络请求。

首先是与Quarkus API对应的FeatureFlag模型。

// Models/FeatureFlag.swift

import Foundation

struct FeatureFlag: Codable, Identifiable {
    let id: Int // PanacheEntity 的 id
    let key: String
    let description: String
    var enabled: Bool
    let targetEnvironment: String
    let updatedAt: String // 在真实项目中会使用 Date 类型和 ISO8601DateFormatter
    
    // Codable 自动处理了驼峰到下划线的转换,如果API返回的是snake_case
    enum CodingKeys: String, CodingKey {
        case id, key, description, enabled, updatedAt
        case targetEnvironment
    }
}

网络服务层负责封装所有API调用。这里的错误处理至关重要,必须能够清晰地向上层传递网络错误、服务器错误或数据解析错误。

// Services/APIService.swift

import Foundation

enum APIError: Error {
    case invalidURL
    case networkError(Error)
    case serverError(statusCode: Int)
    case decodingError(Error)
    case invalidResponse
}

class APIService {
    private let baseURL = "http://localhost:8080/api/v1" // 应从配置中读取

    func fetchFeatureFlags() async throws -> [FeatureFlag] {
        guard let url = URL(string: "\(baseURL)/feature-flags") else {
            throw APIError.invalidURL
        }

        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            
            guard let httpResponse = response as? HTTPURLResponse else {
                throw APIError.invalidResponse
            }
            
            guard (200...299).contains(httpResponse.statusCode) else {
                throw APIError.serverError(statusCode: httpResponse.statusCode)
            }

            let decoder = JSONDecoder()
            return try decoder.decode([FeatureFlag].self, from: data)
        } catch let error as DecodingError {
            throw APIError.decodingError(error)
        } catch {
            throw APIError.networkError(error)
        }
    }

    func updateFeatureFlag(key: String, payload: UpdateFlagPayload) async throws -> FeatureFlag {
        guard let url = URL(string: "\(baseURL)/feature-flags/\(key)") else {
            throw APIError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        // 在真实项目中,这里需要添加认证头
        // request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        request.httpBody = try JSONEncoder().encode(payload)

        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw APIError.serverError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 500)
        }
        
        return try JSONDecoder().decode(FeatureFlag.self, from: data)
    }
}

// 用于更新请求的负载结构体
struct UpdateFlagPayload: Codable {
    let enabled: Bool
    let description: String
    let targetEnvironment: String
}

在SwiftUI视图模型中,我们调用这个服务来驱动UI。

// ViewModels/FeatureFlagViewModel.swift

import SwiftUI

@MainActor
class FeatureFlagViewModel: ObservableObject {
    @Published var flags: [FeatureFlag] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let apiService = APIService()

    func loadFlags() {
        isLoading = true
        errorMessage = nil
        Task {
            do {
                self.flags = try await apiService.fetchFeatureFlags()
            } catch let error as APIError {
                // 将复杂的错误类型转换为用户友好的信息
                self.errorMessage = "Failed to load flags: \(error)"
            } catch {
                self.errorMessage = "An unexpected error occurred."
            }
            isLoading = false
        }
    }
    
    func toggleFlag(flag: FeatureFlag) {
        // ... 实现调用 updateFeatureFlag 的逻辑 ...
    }
}

Sprint 4: 实时同步的挑战与权衡

至此,我们有了一个静态的Web门户和一个动态的iOS应用。但一个新问题浮出水面:当一个SRE在iOS应用上禁用了某个功能开关,Web门户上的信息就过时了,直到下一次CI/CD触发Gatsby重新构建。对于某些关键信息,这种延迟是不可接受的。

我们讨论了两种方案:

  1. Gatsby 客户端补水 (Client-side Hydration): 在Gatsby页面加载后,用useEffect钩子再次请求API,更新数据。这会牺牲一部分SSG的性能优势,并增加客户端的复杂性。
  2. 为iOS应用增加实时推送: 专注于提升iOS应用的实时性,接受Web门户的延迟。

考虑到我们的核心场景是移动端的即时操作,我们选择了方案二,并利用Quarkus对WebSocket的良好支持来增强iOS应用。我们在Quarkus中添加了一个WebSocket端点,每当有功能开关被更新时,就向所有连接的客户端广播一条消息。

// src/main/java/org/acme/idp/websockets/FlagUpdateSocket.java
package org.acme.idp.websockets;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
import org.jboss.logging.Logger;

import java.util.concurrent.CopyOnWriteArrayList;

@ServerEndpoint("/ws/flag-updates")
@ApplicationScoped
public class FlagUpdateSocket {

    private static final Logger LOG = Logger.getLogger(FlagUpdateSocket.class);
    
    // 使用线程安全的列表来存储会话
    CopyOnWriteArrayList<Session> sessions = new CopyOnWriteArrayList<>();

    @OnOpen
    public void onOpen(Session session) {
        sessions.add(session);
        LOG.infov("New WebSocket session opened: {0}", session.getId());
    }

    @OnClose
    public void onClose(Session session) {
        sessions.remove(session);
        LOG.infov("WebSocket session closed: {0}", session.getId());
    }

    // 这个方法可以被其他服务注入并调用
    public void broadcast(String message) {
        sessions.forEach(s -> {
            s.getAsyncRemote().sendText(message, result -> {
                if (result.getException() != null) {
                    LOG.error("Unable to send message via WebSocket", result.getException());
                }
            });
        });
    }
}

FeatureFlagResourceupdate方法成功后,我们注入FlagUpdateSocket并调用broadcast方法。iOS客户端则使用URLSessionWebSocketTask来监听这些消息并实时刷新UI。

这个异构系统的技术选型看似奇怪,但它恰恰是我们团队在特定约束(Java技术栈、追求极致前端性能、要求原生移动体验)和Scrum迭代开发模式下,做出的最务实、最高效的选择。Quarkus作为核心,其灵活性足以支撑一个构建时拉取数据的静态站点和一个需要实时交互的原生应用。

当前架构并非没有局限。Gatsby门户的数据延迟问题依然是潜在的技术债。未来,我们可能会探索一种混合渲染模式,即大部分页面静态生成,但关键的、需要实时性的组件(如状态仪表盘)则在客户端进行渲染。此外,统一的API也面临着为不同客户端优化的问题,服务于Gatsby构建的批量数据接口和用于iOS的细粒度操作接口,其设计哲学不尽相同,或许引入GraphQL作为API网关会是下一个Sprint值得探讨的架构演进方向。


  目录