跳转到内容

会话(Sessions)

会话是网站的重要组成部分,它允许服务器识别来自同一个人的请求,尤其是在服务器端表单验证或页面上没有 JavaScript 时。会话是许多允许用户登录的网站的基本构建块,包括社交、电子商务、商业和教育网站。

在 Remix 中,会话是按路由进行管理的(而不是像 express 中间件那样),在 loaderaction 方法中使用会话存储对象(实现 SessionStorage 接口)。会话存储了解如何解析和生成 cookie,以及如何将会话数据存储在数据库或文件系统中。

Remix 附带几个针对常见场景的预建会话存储选项,还有一个可供您创建自己的会话存储选项:

  • createCookieSessionStorage
  • createMemorySessionStorage
  • createFileSessionStorage (节点)
  • createWorkersKVSessionStorage (Cloudflare Workers)
  • createArcTableSessionStorage (架构师,Amazon DynamoDB)
  • 使用 createSessionStorage 自定义存储

使用会话(Sessions)

这是一个 cookie 会话存储的示例:

app/sessions.ts
import { createCookieSessionStorage } from "@remix-run/node"; // or cloudflare/deno
type SessionData = {
userId: string;
};
type SessionFlashData = {
error: string;
};
const { getSession, commitSession, destroySession } =
createCookieSessionStorage<SessionData, SessionFlashData>(
{
// a Cookie from `createCookie` or the CookieOptions to create one
cookie: {
name: "__session",
// all of these are optional
domain: "remix.run",
// Expires can also be set (although maxAge overrides it when used in combination).
// Note that this method is NOT recommended as `new Date` creates only one date on each server deployment, not a dynamic date in the future!
//
// expires: new Date(Date.now() + 60_000),
httpOnly: true,
maxAge: 60,
path: "/",
sameSite: "lax",
secrets: ["s3cret1"],
secure: true,
},
}
);
export { getSession, commitSession, destroySession };

我们建议在 app/sessions.ts 中设置会话存储对象,以便所有需要访问会话数据的路由都可以从同一位置导入(另请参阅我们的 路由模块约束)。

会话存储对象的输入 / 输出是 HTTP cookie。getSession() 从传入请求的 Cookie 标头中检索当前会话,commitSession()/destroySession() 为传出响应提供 Set-Cookie 标头。

您将使用方法来访问 loaderaction 函数中的会话。

登录表单可能看起来像这样:

app/routes/login.tsx
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";
import { getSession, commitSession } from "../sessions";
export async function loader({
request,
}: LoaderFunctionArgs) {
const session = await getSession(
request.headers.get("Cookie")
);
if (session.has("userId")) {
// Redirect to the home page if they are already signed in.
return redirect("/");
}
const data = { error: session.get("error") };
return json(data, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
export async function action({
request,
}: ActionFunctionArgs) {
const session = await getSession(
request.headers.get("Cookie")
);
const form = await request.formData();
const username = form.get("username");
const password = form.get("password");
const userId = await validateCredentials(
username,
password
);
if (userId == null) {
session.flash("error", "Invalid username/password");
// Redirect back to the login page with errors.
return redirect("/login", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
session.set("userId", userId);
// Login succeeded, send them to the home page.
return redirect("/", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
export default function Login() {
const { error } = useLoaderData<typeof loader>();
return (
<div>
{error ? <div className="error">{error}</div> : null}
<form method="POST">
<div>
<p>Please sign in</p>
</div>
<label>
Username: <input type="text" name="username" />
</label>
<label>
Password:{" "}
<input type="password" name="password" />
</label>
</form>
</div>
);
}

然后注销表单可能看起来像这样:

import { getSession, destroySession } from "../sessions";
export const action = async ({
request,
}: ActionFunctionArgs) => {
const session = await getSession(
request.headers.get("Cookie")
);
return redirect("/login", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
};
export default function LogoutRoute() {
return (
<>
<p>Are you sure you want to log out?</p>
<Form method="post">
<button>Logout</button>
</Form>
<Link to="/">Never mind</Link>
</>
);
}

务必在操作而不是加载器中注销(或执行任何变更)。否则,您的用户将面临 跨站点请求伪造 攻击。此外,Remix 仅在调用操作时重新调用加载器

会话陷阱

由于存在嵌套路由,因此可以调用多个加载器来构建单个页面。使用 session.flash()session.unset() 时,您需要确保请求中的其他加载器不会想要读取该消息,否则您将遇到竞争条件。通常,如果您使用的是闪存,则需要让单个加载器读取它,如果另一个加载器想要闪存消息,请为该加载器使用不同的键。

createSession

待办事项:

isSession 属性

如果对象是 Remix 会话,则返回 true

import { isSession } from "@remix-run/node"; // or cloudflare/deno
const sessionData = { foo: "bar" };
const session = createSession(sessionData, "remix-session");
console.log(isSession(session));
// true

createSessionStorage

如果需要,Remix 可让您轻松地将会话存储在您自己的数据库中。createSessionStorage() API 需要一个 cookie(有关创建 cookie 的选项,请参阅 cookies)和一组用于管理会话数据的创建、读取、更新和删除 (CRUD) 方法。cookie 用于保留会话 ID。

  • 当 cookie 中不存在会话 ID 时,将在首次创建会话时从 commitSession 调用 createData
  • 当 cookie 中存在会话 ID 时,将从 getSession 调用 readData
  • 当 cookie 中已存在会话 ID 时,将从 commitSession 调用 updateData
  • destroySession 中调用 deleteData

以下示例展示了如何使用通用数据库客户端执行此操作:

import { createSessionStorage } from "@remix-run/node"; // or cloudflare/deno
function createDatabaseSessionStorage({
cookie,
host,
port,
}) {
// Configure your database client...
const db = createDatabaseClient(host, port);
return createSessionStorage({
cookie,
async createData(data, expires) {
// `expires` is a Date after which the data should be considered
// invalid. You could use it to invalidate the data somehow or
// automatically purge this record from your database.
const id = await db.insert(data);
return id;
},
async readData(id) {
return (await db.select(id)) || null;
},
async updateData(id, data, expires) {
await db.update(id, data);
},
async deleteData(id) {
await db.delete(id);
},
});
}

然后你可以像这样使用它:

const { getSession, commitSession, destroySession } =
createDatabaseSessionStorage({
host: "localhost",
port: 1234,
cookie: {
name: "__session",
sameSite: "lax",
},
});

createDataupdateDataexpires 参数与 cookie 本身过期且不再有效的 Date 相同。您可以使用此信息自动从数据库中清除会话记录以节省空间,或确保您不会返回任何旧的、过期的 cookie 数据。

createCookieSessionStorage

对于纯粹基于 cookie 的会话(会话数据本身与浏览器一起存储在会话 cookie 中,请参阅cookies),您可以使用 createCookieSessionStorage()

Cookie 会话存储的主要优点是您不需要任何额外的后端服务或数据库即可使用它。在某些负载平衡场景中,它也很有用。但是,基于 Cookie 的会话不得超过浏览器允许的最大 Cookie 长度(通常为 4kb)。

缺点是您必须在几乎每个加载器和操作中提交会话。如果您的加载器或操作完全更改了会话,则必须提交它。这意味着如果您在一个操作中使用 session.flash,然后在另一个操作中使用 session.get,则必须提交它才能使闪现的消息消失。使用其他会话存储策略,您只需在创建时提交它(浏览器 cookie 不需要更改,因为它不存储会话数据,只是在其他地方找到它的密钥)。

import { createCookieSessionStorage } from "@remix-run/node"; // or cloudflare/deno
const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
// a Cookie from `createCookie` or the same CookieOptions to create one
cookie: {
name: "__session",
secrets: ["r3m1xr0ck5"],
sameSite: "lax",
},
});

请注意,其他会话实现将唯一的会话 ID 存储在 cookie 中,并使用该 ID 在真实来源(内存、文件系统、数据库等)中查找会话。在 cookie 会话中,cookie 是真实来源,因此没有现成的唯一 ID。如果您需要在 cookie 会话中跟踪唯一 ID,则需要通过 session.set() 自行添加 ID 值。

createMemorySessionStorage

此存储将所有 cookie 信息保存在服务器的内存中。

这只应在开发中使用。在生产中使用其他方法之一。

app/sessions.ts
import {
createCookie,
createMemorySessionStorage,
} from "@remix-run/node"; // or cloudflare/deno
// In this example the Cookie is created separately.
const sessionCookie = createCookie("__session", {
secrets: ["r3m1xr0ck5"],
sameSite: true,
});
const { getSession, commitSession, destroySession } =
createMemorySessionStorage({
cookie: sessionCookie,
});
export { getSession, commitSession, destroySession };

createFileSessionStorage (节点)

对于文件支持的会话,请使用 createFileSessionStorage()。文件会话存储需要文件系统,但这在大多数运行 express 的云提供商上应该很容易获得,可能还需要一些额外的配置。

文件支持会话的优点是只有会话 ID 存储在 cookie 中,而其余数据都存储在磁盘上的常规文件中,非常适合数据超过 4kb 的会话。

如果您要部署到无服务器功能,请确保您可以访问持久文件系统。如果没有额外的配置,它们通常没有这样的文件系统。

app/sessions.ts
import {
createCookie,
createFileSessionStorage,
} from "@remix-run/node"; // or cloudflare/deno
// In this example the Cookie is created separately.
const sessionCookie = createCookie("__session", {
secrets: ["r3m1xr0ck5"],
sameSite: true,
});
const { getSession, commitSession, destroySession } =
createFileSessionStorage({
// The root directory where you want to store the files.
// Make sure it's writable!
dir: "/app/sessions",
cookie: sessionCookie,
});
export { getSession, commitSession, destroySession };

createWorkersKVSessionStorage(Cloudflare Workers)

对于 Cloudflare Workers KV 支持的会话,使用 createWorkersKVSessionStorage()

KV 支持会话的优势在于,只有会话 ID 存储在 cookie 中,而其余数据都存储在全局复制、低延迟的数据存储中,并且具有低延迟的极高读取量。

app/sessions.server.ts
import {
createCookie,
createWorkersKVSessionStorage,
} from "@remix-run/cloudflare";
// In this example the Cookie is created separately.
const sessionCookie = createCookie("__session", {
secrets: ["r3m1xr0ck5"],
sameSite: true,
});
const { getSession, commitSession, destroySession } =
createWorkersKVSessionStorage({
// The KV Namespace where you want to store sessions
kv: YOUR_NAMESPACE,
cookie: sessionCookie,
});
export { getSession, commitSession, destroySession };

createArcTableSessionStorage (architect, Amazon DynamoDB)

对于 Amazon DynamoDB 支持的会话,使用 createArcTableSessionStorage()

DynamoDB 支持的会话的优势在于,只有会话 ID 存储在 cookie 中,而其余数据则存储在全局复制的低延迟数据存储中,该数据存储具有极高的读取量和低延迟。

app.arc
sessions
_idx *String
_ttl TTL
app/sessions.server.ts
import {
createCookie,
createArcTableSessionStorage,
} from "@remix-run/architect";
// In this example the Cookie is created separately.
const sessionCookie = createCookie("__session", {
secrets: ["r3m1xr0ck5"],
maxAge: 3600,
sameSite: true,
});
const { getSession, commitSession, destroySession } =
createArcTableSessionStorage({
// The name of the table (should match app.arc)
table: "sessions",
// The name of the key used to store the session ID (should match app.arc)
idx: "_idx",
// The name of the key used to store the expiration time (should match app.arc)
ttl: "_ttl",
cookie: sessionCookie,
});
export { getSession, commitSession, destroySession };

Session API

使用 getSession 检索会话后,返回的会话对象具有一些方法和属性:

export async function action({
request,
}: ActionFunctionArgs) {
const session = await getSession(
request.headers.get("Cookie")
);
session.get("foo");
session.has("bar");
// etc.
}

session.has(key)

如果会话具有给定名称的变量,则返回 true

session.has("userId");

session.set(key, value)

设置会话值以供后续请求使用:

session.set("userId", "1234");

session.flash(key, value)

设置会话值,该值将在第一次读取时取消设置。之后,该值将消失。最适用于闪现消息和服务器端表单验证消息:

import { commitSession, getSession } from "../sessions";
export async function action({
params,
request,
}: ActionFunctionArgs) {
const session = await getSession(
request.headers.get("Cookie")
);
const deletedProject = await archiveProject(
params.projectId
);
session.flash(
"globalMessage",
`Project ${deletedProject.name} successfully archived`
);
return redirect("/dashboard", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}

现在我们可以在加载器中读取消息。

每次读取 flash 时,您都必须提交会话。这与您可能习惯的方式不同,某些类型的中间件会自动为您设置 cookie 标头。

import { json } from "@remix-run/node"; // or cloudflare/deno
import {
Meta,
Links,
Scripts,
Outlet,
} from "@remix-run/react";
import { getSession, commitSession } from "./sessions";
export async function loader({
request,
}: LoaderFunctionArgs) {
const session = await getSession(
request.headers.get("Cookie")
);
const message = session.get("globalMessage") || null;
return json(
{ message },
{
headers: {
// only necessary with cookieSessionStorage
"Set-Cookie": await commitSession(session),
},
}
);
}
export default function App() {
const { message } = useLoaderData<typeof loader>();
return (
<html>
<head>
<Meta />
<Links />
</head>
<body>
{message ? (
<div className="flash">{message}</div>
) : null}
<Outlet />
<Scripts />
</body>
</html>
);
}

session.get()

访问先前请求的会话值:

session.get("name");

session.unset()

从会话中删除一个值。

session.unset("name");

使用 cookieSessionStorage 时,每次取消设置时都必须提交会话

export async function loader({
request,
}: LoaderFunctionArgs) {
// ...
return json(data, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}