这是我对之前关于如何使用Next.js构建多租户应用的文章的更新教程,网址为 https://medium.com/@gg.code.latam/how-create-a-multi-tenant-app-with-next-js-13-14-app-router-7a30fb5f8454。
简单总结:多租户应用程序是一种软件,其中单一的应用程序实例服务于多个租户。每个租户都有自己的数据空间和配置,确保每个租户数据的隐私和安全。
在子域的环境中,每个客户通过各自的特定子域访问应用程序的页面。比如说:
tenantA.example.com
tenantB.example.com
tenantC.example.com
这种方法允许在子域级别进行自定义设置,便于数据管理和隔离操作,并且在软件即服务(SaaS)应用程序中非常普遍。
在这次,我们将进一步推进我们的技术栈,不再使用Firebase作为后端服务,而是使用Supabase(一个免费的替代方案,它允许我们使用PostgreSQL并轻松地将其连接到我们的应用程序)。另外,我们将不再使用Vercel来管理子域,而是使用Cloudflare来管理我们的域名和DNS,并使用一个脚本来让localhost上直接无缝使用子域,而无需额外的端口配置。
注意:为了创建一个支持多租户的应用,其中每个“租户”都有自己的子域名,我们需要一个只有一个顶级域名的域名(例如,.com, .ar, .uy, .tech)。像 ‘myapp.vercel.app’ 这样的域名对我们来说是不适用的,因为我们需要创建的子域名是 ‘subdomain.myapp.vercel.app’,这是不允许的。
既然已经明确了这一点,我们就来开始项目的初始设置吧。
这次我使用的是 Next.js 14.2.5,按照以下步骤安装:npx create-next-app@latest,并配置使用 TypeScript、Tailwind CSS、ESLint 和 import 通配符(不包括 src 目录,但你可以按需使用)。安装完 Next.js 后,我们还需要做以下几件事:在 Supabase 上创建一个账户并设置服务器(特别注意,保存 Supabase 提供的项目密码!),以及在 Cloudflare 上创建一个账户并把你的域名委托给 Cloudflare(我们之后会将域名委托给 Vercel)。
该项目新仓库的链接:https://github.com/GGCodeLatam/next-multitenant-2024
好了,我们开始在 Next.js 项目里写代码吧。
我们先从安装 Prisma 以及所有依赖项开始吧:
npm i @prisma/client @neondatabase/serverless @prisma/adapter-pg @supabase/ssr @supabase/supabase-js pg # 瑞典语注释:此命令用于安装项目所需的 npm 包
npm i -D prisma @types/node @types/pg concurrently cross-env http-proxy ts-node # 安装依赖:prisma、@types/node、@types/pg、concurrently、cross-env、http-proxy、ts-node
安装了 Prisma 之后,让我们开始用 Prisma 吧:
# 启动 Prisma 的命令可以根据具体版本和使用场景有所不同,这里仅提供通用示例 例如,您可以使用以下命令来启动 Prisma: npx prisma db push npx prisma generate
npx prisma init
运行此命令以初始化 Prisma 项目。
这将在我们的项目中创建一个 prisma 文件夹,在该文件夹内我们将找到 schema.prisma 文件。在本教程中,我们将编辑此文件以定义租户模型,以便在我们的 Supabase 数据库中创建子域。
客户端生成器 { 提供程序 = "prisma-client-js" } 数据源 { 提供程序 = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") } 模型定义 Tenant { id String @id @default(生成唯一标识符()) name String subdomain String @unique createdAt DateTime @default(当前时间()) updatedAt DateTime @updatedAt 更新时间 DateTime @updatedAt }
现在我们已经在数据库中设置了Tenant模型,接下来我们需要找所需的环境变量。Supabase已经提供了适用于Next.js 14的特定连接,因此我们将从那里获取所需的变量:它将为我们提供特定于我们框架(例如Prisma)和ORM的变量。我们需要将以下内容添加到.env文件中:
DATABASE_URL (数据库URL) DIRECT_URL (直接URL) NEXT_PUBLIC_SUPABASE_URL (公开的Supabase URL) NEXT_PUBLIC_SUPABASE_ANON_KEY (公开的Supabase匿名密钥)
一旦我们拿到这些 Supabase 凭证,我们可以把它们放到 .env 文件里。在 GitHub 项目里,我已经放了一个相同的 .env.example 文件给你。
# 特定于 Next.js 的环境变量 NEXT_PUBLIC_API_URL="https://domain.ar/api" # Node 环境 NODE_ENV="production" # other translations remain unchanged # 通过 Supavisor 连接池连接到 Supabase。 DATABASE_URL="postgresql://postgres.[projectname]:[password]@aws-0-us-west-1.pooler.supabase.com:6543/postgres?pgbouncer=true" # 直接连接到数据库,用于进行迁移。 DIRECT_URL="postgresql://postgres.[projectname]:[password]@aws-0-us-west-1.pooler.supabase.com:5432/postgres" # Supabase 公共 URL 和匿名密钥 NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY=
我们现在有了这些变量,可以开始创建模型了。
运行以下命令: `npx prisma generate`
现在,让我们将生成的表格直接移动到我们在Supabase上的项目中。
使用命令 `npx prisma migrate dev --name init` 来初始化数据库迁移
这应该会在控制台显示一条消息,说明它正在从 prisma/schema.prisma 加载模式文件,并告知这些信息会被送到哪里(这可能需要几分钟)。
现在 Supabase 已经有了这个结构,下一步是创建一些子域名,以便我们可以同时在本地和生产环境中测试我们的应用。让我们创建它们:
首先,我们在‘schema.prisma’所在的文件夹里新建一个叫做‘seed.mts’的文件。
import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() async function main() { // 主函数,创建多个租户 await prisma.tenant.createMany({ data: [ { name: 'Tenant 1', subdomain: 'tenant1' }, { name: 'Tenant 2', subdomain: 'tenant2' }, { name: 'Test', subdomain: 'test' }, ], }) } main() .catch((e) => { // 输出错误信息到控制台 console.error(e) process.exit(1) // 退出进程 }) .finally(async () => { // 断开与数据库的连接 await prisma.$disconnect() })
一旦我们拿到了这个文件,让我们编辑 package.json
,以运行用于将此信息发送到我们服务器的命令:
"prisma": { "seed": "node --loader ts-node/esm prisma/seed.mts" },
有了这行代码在我们的package.json文件中,我们就可以运行相应的命令:
# 代码段保持不变
运行下面的命令来初始化数据库:npx prisma db seed
完成这一步后,我们就可以将数据存储在 Supabase 中。现在,让我们为 Next.js 中的多租户应用创建结构。首先,我们需要创建子域名的路径和APIs,配置 next.config 文件,并在项目根目录中添加 middleware.ts 文件。
middleware.ts: 中间件.ts
import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export const config = { matcher: [ "/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)", ], }; export async function middleware(req: NextRequest) { const url = req.nextUrl; let hostname = req.headers.get("host") || ''; // 去掉存在的端口号 hostname = hostname.split(':')[0]; // 定义允许的域名(包括主域名和localhost) const allowedDomains = ["tudominio.ar", "www.tudominio.ar", "localhost"]; // 检查当前域名是否在允许的域名列表中 const isMainDomain = allowedDomains.includes(hostname); // 如果不是主域名,提取子域名 const subdomain = isMainDomain ? null : hostname.split('.')[0]; console.log('中间件: 域名:', hostname); console.log('中间件: 子域名:', subdomain); // 如果是主域名,放行请求 if (isMainDomain) { console.log('中间件: 检测到主域名,放行请求'); return NextResponse.next(); } // 处理子域名逻辑 if (subdomain) { try { // 使用fetch确认子域名是否存在 const response = await fetch(`${url.origin}/api/tenant?subdomain=${subdomain}`); if (response.ok) { console.log('中间件: 检测到有效的子域名,'); // 将URL重写为基于子域名的动态路由 return NextResponse.rewrite(new URL(`/${subdomain}${url.pathname}`, req.url)); } } catch (error) { console.error('中间件: 租户获取错误:', error); } } console.log('中间件: 无效的子域名或域名,返回404响应'); // 如果以上条件都不符合,返回404响应 return new NextResponse(null, { status: 404 }); }
next.config.mjs
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, async rewrites() { return [ { source: '/:path*', destination: '/:path*', }, { source: '/', destination: '/api/tenant', }, ]; }, }; export default nextConfig;
app/api/租户管理/route.ts:
import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/prisma' export async function GET(request: NextRequest) { const subdomain = request.nextUrl.searchParams.get('subdomain') console.log('API: 收到请求的子域:', subdomain) if (!subdomain) { console.log('API: 子域是必填的') return NextResponse.json({ error: '子域是必填的' }, { status: 400 }) } try { const tenant = await prisma.tenant.findUnique({ where: { subdomain }, select: { id: true, name: true, subdomain: true } }) console.log('API: 找到租户的信息:', tenant) if (!tenant) { console.log('API: 未找到相关租户') return NextResponse.json({ error: '未找到相关租户' }, { status: 404 }) } return NextResponse.json(tenant) } catch (error) { console.error('API: 获取租户信息时出错:', error) return NextResponse.json({ error: '服务器内部错误' }, { status: 500 }) } }
app/api/create-tenant/route.ts:创建租户API路由文件
// app/api/create-tenant/route.ts import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/prisma' export async function POST(request: NextRequest) { try { const { name, subdomain } = await request.json() if (!name || !subdomain) { return NextResponse.json({ error: '名称和子域都是必填项' }, { status: 400 }) } const existingTenant = await prisma.tenant.findUnique({ where: { subdomain }, }) if (existingTenant) { return NextResponse.json({ error: '该子域已存在' }, { status: 409 }) } const newTenant = await prisma.tenant.create({ data: { name, subdomain }, }) return NextResponse.json(newTenant, { status: 201 }) } catch (error) { console.error('创建租户时发生错误:', error) return NextResponse.json({ error: '服务器内部发生了错误' }, { status: 500 }) } }
app/[subdomain]/page.tsx:
import { notFound } from 'next/navigation' import prisma from '@/lib/prisma' export default async function SubdomainPage({ params }: { params: { subdomain: string } }) { const { subdomain } = params console.log('SubdomainPage: 正在渲染子域页面:', subdomain) try { const tenant = await prisma.tenant.findUnique({ where: { subdomain }, }) console.log('SubdomainPage: 正在获取租户:', tenant) if (!tenant) { console.log('SubdomainPage: 未找到租户,即将重定向到404页面') notFound() } return ( <div className="flex flex-col items-center justify-center min-h-screen py-2"> <h1 className="text-4xl font-bold">欢迎来到 {tenant.name}</h1> <p>这是一个为 {subdomain} 的多租户站点</p> <pre>{JSON.stringify(tenant, null, 2)}</pre> </div> ) } catch (error) { console.error('SubdomainPage: 在获取租户时出错:', error) return ( <div className="flex flex-col items-center justify-center min-h-screen py-2"> <h1 className="text-4xl font-bold text-red-500">错误</h1> <p>加载租户信息时出现错误。</p> <pre>{JSON.stringify(error, null, 2)}</pre> </div> ) } }
并在项目根目录下创建一个名为‘lib’的文件夹,并将这两个文件放入其中。
lib/prisma.ts:
import { PrismaClient } from '@prisma/client' declare global { var prisma: PrismaClient | undefined } const prisma = global.prisma || new PrismaClient() if (process.env.NODE_ENV !== 'production') global.prisma = prisma export default prisma
lib/tenants.ts:
import prisma from './prisma' export async function getTenantBySubdomain(subdomain: string) { if (process.env.NEXT_RUNTIME === 'edge') { // 在Edge环境中,我们调用一个API const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/tenants/${subdomain}`) if (!response.ok) { throw new Error('获取租户信息失败') } return response.json() } else { // 在其他环境中,我们正常使用Prisma return prisma.tenant.findUnique({ where: { subdomain }, }) } } export async function getAllTenants() { // 检索所有租户 return prisma.tenant.findMany() }
现在配置已经完成,让我们在本地运行测试我们的项目(在项目根目录创建一个名为 proxy-server.js 的文件)。
import httpProxy from 'http-proxy'; import http from 'http'; const proxy = httpProxy.createProxyServer({ ws: true, xfwd: true }); const NEXT_SERVER_PORT = 3000; const PROXY_PORT = 8080; const server = http.createServer((req, res) => { const host = req.headers.host; console.log(`代理: 收到针对主机 ${host} 的请求, 路径: ${req.url}`); // 将所有请求转发到你的 Next.js 应用 proxy.web(req, res, { target: `http://localhost:${NEXT_SERVER_PORT}`, changeOrigin: false, headers: { 'Host': host, } }); }); server.on('upgrade', (req, socket, head) => { proxy.ws(req, socket, head, { target: `ws://localhost:${NEXT_SERVER_PORT}`, changeOrigin: false, }); }); server.listen(PROXY_PORT, () => { console.log(`代理服务器正在运行于 http://localhost:${PROXY_PORT}`); console.log(`你现在可以通过子域名访问应用,例如: http://tenant1.localhost:${PROXY_PORT}`); }); proxy.on('error', (err, req, res) => { console.error('代理错误:', err); if (res.writeHead) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('代理服务器出错了。'); } }); proxy.on('proxyReq', (proxyReq, req, res, options) => { console.log(`代理: 正在转发请求到: ${proxyReq.path}`); }); proxy.on('proxyRes', (proxyRes, req, res) => { console.log(`代理: 接收到来自代理的响应状态: ${proxyRes.statusCode}`); });
在package.json中设置自定义的本地启动脚本内容。
"scripts": { "dev": "next dev", "build": "prisma generate && next build", "start": "next start", "lint": "next lint", "proxy": "node proxy-server.js", "dev:proxy": "跨环境设置环境变量 (cross-env) NODE_ENV=开发 development 并行运行 \"npm run dev\" \"npm run proxy\"" }, // 开发环境启动开发服务器和代理服务器
请注意:在构建时,它现在也会在构建 Next 之前先生成 Prisma 文件。
现在,有了它,我们可以用以下命令本地启动并运行该项目。
npm run dev:proxy
这将允许您直接使用8080端口,无需通过代理或其他中间设备。我们可以直接进入位于‘app’内的主页面(就像在Next.js中一样),并通过在浏览器中输入‘tenant1.localhost:8080’来访问与子域相关的一切内容。
现在我们来添加一个组件以直接在我们的应用中创建子域名。
components/Home.jsx:
'use client'; import React, { useState } from 'react'; export default function Home() { const [name, setName] = useState(''); const [subdomain, setSubdomain] = useState(''); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const handleSubmit = async (e) => { e.preventDefault(); setMessage(''); setError(''); try { const response = await fetch('/api/create-tenant', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, subdomain }), }); if (response.ok) { const data = await response.json(); setMessage(`成功创建租户:${data.name} (${data.subdomain})`); setName(''); setSubdomain(''); } else { const errorData = await response.json(); setError(errorData.error || '租户创建失败'); } } catch (error) { setError('创建租户时出错了'); } }; return ( <div className="flex flex-col items-center justify-center min-h-screen py-2"> <h1 className="text-4xl font-bold mb-4">欢迎来到多租户应用的世界</h1> <p className="text-xl mb-8">创建个新的租户开始使用吧。</p> <form onSubmit={handleSubmit} className="w-full max-w-md"> <div className="mb-4"> <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="租户名称" required /> </div> <div className="mb-4"> <input type="text" value={subdomain} onChange={(e) => setSubdomain(e.target.value)} placeholder="子域名:" required /> </div> <button type="submit" className="w-full">创建租户</button> </form> {message && ( <div className="mt-4"> <h2>成功了</h2> <p>{message}</p> </div> )} {error && ( <div variant="destructive" className="mt-4"> <h3>出错</h3> <p>{error}</p> </div> )} </div> ); }
让我们把它加到首页吧:
import Image from "next/image"; import Home from "./components/Home"; export default function Page() { return ( <main className="flex min-h-screen flex-col items-center justify-between p-24"> <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex"> <Home /> </div> </main> ); }
就这样,我们现在就能在本地和生产环境中创建子域。
最后一步就是部署了。只需将我们的代码库推送到 GitHub,然后在 Vercel 上部署。然后,在域名设置里,添加您的域名及其通配符(例如:*.example.com)。我建议使用 CloudFlare 进行 DNS 管理。从这里起,我们有了坚实的基础来构建一个多租户应用程序,并可以为许多客户提供包含域名的应用程序。希望这些信息对您有所帮助,您有任何问题都可以在评论中提出。
该项目新仓库的链接:https://github.com/GGCodeLatam/next-multitenant-2024