会话
本页内容

会话

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

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

Remix 附带了一些针对常见场景的预构建会话存储选项,以及一个用于创建自定义存储的选项

  • createCookieSessionStorage
  • createMemorySessionStorage
  • createFileSessionStorage (Node.js)
  • createWorkersKVSessionStorage (Cloudflare Workers)
  • createArcTableSessionStorage (Architect,Amazon DynamoDB)
  • 使用 createSessionStorage 自定义存储

使用会话

这是一个 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 函数中使用方法来访问会话。

登录表单可能如下所示

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>
    </>
  );
}

重要的是,你必须在 action 中注销(或执行任何修改),而不是在 loader 中。否则,你将使你的用户面临 跨站点请求伪造 攻击。此外,Remix 仅在调用 action 时重新调用 loader

会话注意事项

由于嵌套路由,可以调用多个加载器来构建单个页面。当使用 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 的选项,请参阅 cookie)以及一组用于管理会话数据的创建、读取、更新和删除 (CRUD) 方法。Cookie 用于持久化会话 ID。

  • createData 将在没有 cookie 中的会话 ID 时,从 commitSession 中调用,用于初始会话创建。
  • readData 将在 cookie 中存在会话 ID 时,从 getSession 中调用。
  • updateData 将在 cookie 中已存在会话 ID 时,从 commitSession 中调用。
  • deleteDatadestroySession 中调用。

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

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 中,请参阅 cookie),你可以使用 createCookieSessionStorage()

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

缺点是你必须在几乎每个加载器和操作中都 commitSession。如果你的加载器或操作更改了会话,则必须提交它。这意味着如果你在操作中 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 信息保存在服务器的内存中。

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

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 (Node.js)

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

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

如果你部署到无服务器函数,请确保你可以访问持久性文件系统。它们通常没有持久性文件系统,除非进行额外配置。

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 中,而其余数据存储在全局复制的、低延迟的数据存储中,具有极高的读取量和低延迟。

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 作为 Session 存储的优势在于,只有 Session ID 会存储在 Cookie 中,而其余数据则存储在全局复制、低延迟的数据存储中,该存储具有极高的读取量和低延迟。

# app.arc
sessions
  _idx *String
  _ttl TTL
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 获取 Session 后,返回的 Session 对象包含一些方法和属性。

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

session.has(key)

如果 Session 中存在具有给定 name 的变量,则返回 true

session.has("userId");

session.set(key, value)

设置 Session 值,以便在后续请求中使用。

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

session.flash(key, value)

设置一个 Session 值,该值在第一次读取时会被取消设置。之后,它就会消失。最常用于“闪存消息”和服务器端表单验证消息。

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,都必须提交 Session。这与您可能习惯的不同,在某些类型的中间件中,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 值。

session.get("name");

session.unset()

从 Session 中删除一个值。

session.unset("name");

使用 cookieSessionStorage 时,无论何时 unset 都必须提交 Session。

export async function loader({
  request,
}: LoaderFunctionArgs) {
  // ...

  return json(data, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}
文档和示例根据以下许可证授权 MIT