在 Vercel Functions 上构建 GraphQL 网关以服务 Android 客户端的复杂图谱查询


一个看似简单的业务需求摆在了面前:在 Android 应用中展示用户的“二度人脉中拥有共同兴趣的好友”。具体来说,我们需要获取当前用户的好友的好友(二度人脉),并筛选出那些与当前用户至少有一个共同兴趣的人。数据结构在概念上是清晰的。

graph TD
    A(当前用户) -->|好友| B(好友1)
    A -->|好友| C(好友2)
    B -->|好友| D(二度人脉1)
    B -->|好友| E(二度人脉2)
    C -->|好友| E
    A ---|拥有兴趣| I1(兴趣A)
    D ---|拥有兴趣| I1
    E ---|拥有兴趣| I2(兴趣B)

    subgraph "查询目标"
        D
    end

    style D fill:#f9f,stroke:#333,stroke-width:2px

这个查询的挑战不在于理解,而在于实现。在一个关系型数据库中,这将涉及多次自连接(self-join),随着用户基数和关系链的增长,查询性能会急剧下降。这迫使我们重新审视数据获取层的架构。

方案 A:传统的 RESTful API 与关系型数据库

这是最常规的思路。我们可以设计几个 API 端点来组合这个查询。

  1. GET /users/{userId}/friends: 获取用户的好友列表。
  2. GET /users/{userId}/interests: 获取用户的兴趣列表。

在客户端,实现逻辑会是这样:

  1. 调用 /users/me/friends 获取好友列表 [friendA, friendB, ...]
  2. 调用 /users/me/interests 获取我的兴趣列表 [interestX, interestY, ...]
  3. 对于 friendA, friendB, ... 中的每一个好友,并行调用 /users/{friendId}/friends 获取他们的好友列表(即我的二度人脉)。
  4. 将所有二度人脉去重。
  5. 对于去重后的每一个二度人脉,调用 /users/{contactId}/interests
  6. 在客户端逻辑中,比较每个二度人脉的兴趣和我的兴趣,找出交集。

这种方法的弊端显而易见:

  • 多次网络往返 (Multiple Round-trips): 客户端需要发起 1 (我的好友) + 1 (我的兴趣) + N (每个好友的好友) + M (每个二度人脉的兴趣) 次 API 调用。这在移动网络环境下是灾难性的。
  • 过度抓取 (Over-fetching): 为了获取兴趣,我们可能需要拉取完整的 User 或 Interest 对象,而我们需要的仅仅是它们的 ID 或名称。
  • 后端耦合与僵化: 为了解决上述问题,后端可能会创建一个专门的端点,如 GET /users/me/recommended-contacts。这虽然解决了客户端的多次请求问题,但却制造了新的问题。如果产品需求稍有变更,比如“需要展示共同兴趣的具体内容”,或者“筛选条件增加地理位置”,后端就需要修改甚至新增端点。这导致 API 变得僵化,前后端开发紧密耦合。
  • 数据库性能瓶颈: 即便在后端聚合,关系型数据库处理这种多层深度的关联查询(JOINs on JOINs)时,性能也会随着数据量的增加而显著退化。

在真实项目中,这种方案在原型阶段尚可,但无法支撑规模化、快速迭代的生产环境。

方案 B:GraphQL 网关 + 图数据库 + Serverless

为了解决 REST 方案的根本问题——数据关系的表达能力不足和客户端的灵活性缺失,我们转向了一个完全不同的架构。

  • **数据存储层:NoSQL 图数据库 (Neo4j)**。图数据库是为处理关系而生的。上述查询在图数据库中是一个简单的图遍历模式匹配,性能极高。
  • API 层:GraphQL。GraphQL 允许客户端精确声明其需要的数据结构,一次请求即可获取所有需要的信息,完美解决了多次往返和数据冗余问题。其查询语言的结构与图数据模型天然契合。
  • 执行环境:Vercel Functions。使用 Serverless 函数作为 GraphQL 服务器,可以获得极低的运维成本、自动弹性伸缩以及与前端框架(如 Next.js)的无缝集成。这让我们能专注于业务逻辑,而不是服务器管理。

这个架构的整体数据流如下:

sequenceDiagram
    participant AndroidClient as Android 客户端
    participant VercelEdge as Vercel Edge Network
    participant GraphQLFunction as Vercel Function (GraphQL Server)
    participant GraphDB as Neo4j Aura (图数据库)

    AndroidClient->>VercelEdge: POST /api/graphql (包含GraphQL查询)
    VercelEdge->>GraphQLFunction: 转发请求
    GraphQLFunction->>GraphQLFunction: 解析查询,执行 Resolver
    GraphQLFunction->>GraphDB: 执行 Cypher 查询
    GraphDB-->>GraphQLFunction: 返回图数据
    GraphQLFunction->>GraphQLFunction: 将数据格式化为 JSON
    GraphQLFunction-->>VercelEdge: 返回 GraphQL 响应
    VercelEdge-->>AndroidClient: 返回 JSON 响应

这个方案的优势是决定性的:客户端的灵活性、单次网络请求、与数据模型高度匹配的查询方式,以及后端的可扩展性和低维护成本。因此,我们决定采用此方案。

核心实现概览:Vercel 上的 GraphQL 服务

我们将使用 Next.js 的 API Routes 来部署 Vercel Function。这提供了极佳的开发体验。数据库选用 Neo4j Aura,一个全托管的云图数据库服务。

1. 项目结构与环境配置

在 Next.js 项目中,我们在 pages/api/graphql.ts 创建 GraphQL 服务。

/my-app
|-- /pages
|   |-- /api
|   |   |-- graphql.ts  // 我们的 GraphQL Serverless Function
|-- /lib
|   |-- neo4j.ts        // Neo4j 驱动单例
|   |-- schema.ts       // GraphQL Schema 和 Resolvers
|-- package.json
|-- .env.local          // 环境变量

环境变量 .env.local 存储数据库连接信息,严禁硬编码在代码中。

# .env.local
NEO4J_URI="neo4j+s://xxxx.databases.neo4j.io"
NEO4J_USERNAME="neo4j"
NEO4J_PASSWORD="your-secure-password"

2. 管理数据库连接

在 Serverless 环境中,正确管理数据库连接至关重要。为每个请求创建新连接会耗尽资源并增加延迟。我们必须使用单例模式来复用驱动实例。

lib/neo4j.ts:

import neo4j, { Driver } from 'neo4j-driver';

// 全局变量用于缓存驱动实例
let driver: Driver | undefined;

/**
 * 获取 Neo4j Driver 的单例实例。
 * 在 Serverless 环境中,这确保我们在函数调用之间复用 TCP 连接,
 * 避免了每次请求都进行昂贵的连接握手。
 * @returns {Driver} Neo4j Driver 实例
 */
function getDriver(): Driver {
  if (!driver) {
    // 检查环境变量是否存在,这是生产级代码的必要步骤
    if (!process.env.NEO4J_URI || !process.env.NEO4J_USERNAME || !process.env.NEO4J_PASSWORD) {
      throw new Error('NEO4J_URI, NEO4J_USERNAME, and NEO4J_PASSWORD must be defined in environment variables.');
    }

    console.log('Creating new Neo4j driver instance...');
    driver = neo4j.driver(
      process.env.NEO4J_URI,
      neo4j.auth.basic(process.env.NEO4J_USERNAME, process.env.NEO4J_PASSWORD)
    );

    // 优雅地处理进程关闭,虽然在 Serverless 中不一定会被触发,但是个好习惯
    process.on('exit', async () => {
      if (driver) {
        console.log('Closing Neo4j driver...');
        await driver.close();
      }
    });
  }
  return driver;
}

export default getDriver;

3. 定义 GraphQL Schema 和 Resolvers

这是 GraphQL 服务的核心。Schema 定义了数据模型和可用的查询,Resolvers 则负责执行查询并返回数据。

lib/schema.ts:

import { gql } from 'apollo-server-micro';
import getDriver from './neo4j';
import { Neo4jGraphQL } from '@neo4j/graphql';

// 1. 定义 GraphQL Schema (SDL)
// 我们定义了 User 和 Interest 两种节点,以及它们之间的关系
export const typeDefs = gql`
  type User {
    userId: ID!
    name: String
    friends: [User!]! @relationship(type: "FRIENDS_WITH", direction: OUT)
    interests: [Interest!]! @relationship(type: "HAS_INTEREST", direction: OUT)
  }

  type Interest {
    name: String!
    users: [User!]! @relationship(type: "HAS_INTEREST", direction: IN)
  }

  # 扩展 Query 类型,定义我们的自定义查询
  type Query {
    """
    获取指定用户的二度人脉中,与该用户有共同兴趣的用户列表。
    这是一个复杂的图遍历查询,是图数据库的典型应用场景。
    """
    findSecondDegreeContactsWithCommonInterests(userId: ID!): [User!]!
  }
`;

// 2. 实现自定义查询的 Resolver
export const resolvers = {
  Query: {
    findSecondDegreeContactsWithCommonInterests: async (_source: any, args: { userId: string }, context: any, _info: any) => {
      const driver = getDriver();
      const session = driver.session();

      const { userId } = args;
      
      // 这是核心的 Cypher 查询
      // Cypher 是一种声明式的图查询语言,非常直观
      const cypherQuery = `
        // 1. 匹配起始用户 (u1) 和他们的兴趣 (i)
        MATCH (u1:User {userId: $userId})-[:HAS_INTEREST]->(i:Interest)
        // 2. 匹配起始用户的好友 (u2, 一度人脉)
        MATCH (u1)-[:FRIENDS_WITH]->(u2:User)
        // 3. 匹配好友的好友 (u3, 二度人脉)
        MATCH (u2)-[:FRIENDS_WITH]->(u3:User)
        // 4. 确保二度人脉 (u3) 不是起始用户自己,也不是一度人脉
        WHERE u3 <> u1 AND NOT (u1)-[:FRIENDS_WITH]->(u3)
        // 5. 关键筛选:确保二度人脉 (u3) 拥有与起始用户 (u1) 相同的兴趣 (i)
        AND (u3)-[:HAS_INTEREST]->(i)
        // 6. 返回去重后的二度人脉
        RETURN DISTINCT u3
      `;

      try {
        const result = await session.run(cypherQuery, { userId });
        
        // 将 Neo4j 返回的记录映射为 GraphQL 需要的 User 对象数组
        const users = result.records.map(record => record.get('u3').properties);
        
        // 在生产环境中,这里应该有更详细的日志记录
        console.log(`Found ${users.length} contacts for user ${userId}`);
        
        return users;
      } catch (error) {
        // 生产级的错误处理和日志
        console.error('Error executing Cypher query:', error);
        // 不应将内部数据库错误直接暴露给客户端,而是抛出一个通用的 GraphQL 错误
        throw new Error('Failed to fetch recommended contacts.');
      } finally {
        // 确保会话被关闭,释放连接回连接池
        await session.close();
      }
    },
  },
};

Cypher 查询是这里的关键。它以一种非常自然的方式描述了我们想要的图模式,这比 SQL 的多层 JOIN 要清晰和高效得多。

4. 部署 Serverless Function

最后,我们将 Apollo Server 包装在 Vercel Function 中。

pages/api/graphql.ts:

import { ApolloServer } from 'apollo-server-micro';
import { typeDefs, resolvers } from '../../lib/schema';
import { ApolloServerPluginLandingPageGraphQLPlayground } from 'apollo-server-core';

// 配置 Apollo Server
const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  // 在开发环境中启用 GraphQL Playground UI,方便调试
  plugins: [
    process.env.NODE_ENV === 'development'
      ? ApolloServerPluginLandingPageGraphQLPlayground()
      : {} as any,
  ],
  // 出于安全考虑,生产环境默认不应开启 introspection
  introspection: process.env.NODE_ENV !== 'production',
});

// 启动服务器
const startServer = apolloServer.start();

export const config = {
  api: {
    bodyParser: false, // 我们让 apollo-server-micro 自己处理 body 解析
  },
};

export default async function handler(req: any, res: any) {
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  res.setHeader(
    'Access-Control-Allow-Origin',
    '*' // 在生产中应配置为你的前端域名
  );
  res.setHeader(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept, Authorization'
  );
  if (req.method === 'OPTIONS') {
    res.end();
    return false;
  }
  
  await startServer;
  await apolloServer.createHandler({ path: '/api/graphql' })(req, res);
}

部署到 Vercel 后,我们就有了一个位于 /api/graphql 的、可公开访问的、自动伸缩的 GraphQL 端点。

Android 客户端集成

在 Android 端,我们使用 Apollo Kotlin 库来与 GraphQL API 交互。

1. Gradle 配置与代码生成

build.gradle.kts 中添加 Apollo 插件和依赖。

// build.gradle.kts (app module)
plugins {
    id("com.android.application")
    kotlin("android")
    id("com.apollographql.apollo3").version("3.8.2")
}

android { ... }

dependencies {
    implementation("com.apollographql.apollo3:apollo-runtime:3.8.2")
}

apollo {
    service("service") {
        packageName.set("com.example.myapp.graphql")
        // schemaFile.set(file("src/main/graphql/schema.graphqls")) // 可选,用于本地 schema
    }
}

2. 定义 GraphQL 查询

src/main/graphql 目录下创建 .graphql 文件。Apollo 插件会自动根据此文件生成类型安全的 Kotlin 模型和查询类。

FindContacts.graphql:

query FindSecondDegreeContacts($userId: ID!) {
  findSecondDegreeContactsWithCommonInterests(userId: $userId) {
    userId
    name
    # 我们可以按需请求更多字段,比如他们的兴趣
    interests {
      name
    }
  }
}

这种声明式的数据请求方式是 GraphQL 的核心优势。如果未来我们需要显示用户的头像 URL,只需在客户端查询中添加 avatarUrl 字段即可,后端代码无需任何改动。

3. 执行查询

在 ViewModel 或 Repository 中,我们可以使用生成的代码来执行查询。

import com.apollographql.apollo3.ApolloClient
import com.example.myapp.graphql.FindSecondDegreeContactsQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext

class UserRepository(private val apolloClient: ApolloClient) {

    // 单元测试思路:可以 mock ApolloClient,让它返回预设的 ApolloResponse
    // 或者 mock NetworkTransport,直接控制网络层面的响应
    suspend fun findRecommendedContacts(userId: String): Flow<Result<List<FindSecondDegreeContactsQuery.FindSecondDegreeContactsWithCommonInterest>>> {
        return flow {
            try {
                // 确保网络请求在 IO 线程执行
                val response = withContext(Dispatchers.IO) {
                    apolloClient.query(FindSecondDegreeContactsQuery(userId = userId)).execute()
                }

                // Apollo 提供了强大的错误处理机制
                if (response.hasErrors()) {
                    val errors = response.errors?.joinToString { it.message } ?: "Unknown GraphQL error"
                    // 在真实应用中,这里应记录错误日志
                    emit(Result.failure(RuntimeException("GraphQL Error: $errors")))
                    return@flow
                }
                
                val contacts = response.data?.findSecondDegreeContactsWithCommonInterests
                if (contacts != null) {
                    emit(Result.success(contacts))
                } else {
                    emit(Result.success(emptyList()))
                }

            } catch (e: Exception) {
                // 处理网络异常等
                emit(Result.failure(e))
            }
        }
    }
}

// ApolloClient 的初始化
val apolloClient = ApolloClient.Builder()
    .serverUrl("https://your-vercel-app.vercel.app/api/graphql")
    // ... 添加认证拦截器等
    .build()

这段 Kotlin 代码是类型安全的。response.data 的结构完全由 FindContacts.graphql 文件定义,任何字段的拼写错误或类型不匹配都会在编译时被发现,极大地提高了代码的健壮性。

架构的扩展性与局限性

这个架构的优势在于其灵活性和对复杂关系的优雅处理。当产品需求演进,需要更复杂的推荐算法(如“三度人脉中与我有相同职业和兴趣的人”)时,我们只需:

  1. schema.ts 中定义一个新的 Query 类型。
  2. 实现其对应的 resolver,编写新的 Cypher 查询。
  3. 在 Android 客户端添加一个新的 .graphql 查询文件。
    整个过程无需修改任何已有的 API,前后端职责分离清晰。

当然,这个方案并非没有权衡。Vercel Functions 的冷启动问题可能会给某些请求带来额外的延迟,尽管对于非实时性要求极高的场景,这种延迟通常在可接受范围内。管理图数据库的模式(schema)演进也需要规范的流程。此外,GraphQL 的学习曲线比 REST 稍陡峭,引入了一套新的概念和工具链。对于简单的、实体间关系不复杂的 CRUD 应用,这套架构可能属于“过度设计”。但对于以“关系”为核心、需要灵活数据查询的移动应用而言,它提供了一个性能优异、可维护性高的解决方案。


  目录