本篇文章假定读者已经拥有OpenAPI帐号,并且已经获取了API访问的secret key。
本文部分内容由ChatGPT生成

ChatGPT网页版开发

本章节描述了如何从零开发一个私有化版本的 ChatGPT 网站,主要使用技术栈为 NextJS + TailwindCSS + chatgpt ,其中 NextJS 作为 React 的全栈框架,能够快速搭建包含前后端的 React 应用,TailwindCSS 则提供了较为便利的样式变量以及移动端的适配,最后通过 NodeJS 的API库 chatgpt 来调用 OpenAI 进行交互。

完整的项目代码可以在 github 上查看 github/helianthusw…

NextJS进行全栈开发

在官方介绍中,NextJS 提供了生产环境所需的所有功能以及最佳的开发体验:包括静态及服务器端融合渲染、 支持 TypeScript、智能化打包、 路由预取等功能,因此它是当前 React 技术栈最为流行的框架。

对我个人而言,一是考虑到项目要通过服务端去代理请求,需要一个全栈的技术方案;另一个也是想学习一下这个框架,所以整个项目的结构以及性能都不是最优,也请读者谅解。

项目初始化

使用以下命令创建一个新的 NextJS 项目:

npx create-next-app@latest --typescript
# or
yarn create next-app --typescript

根据提示语配置完整个项目后,可以得到一个如下目录结构的项目:

├── README.md            # 项目的README文件
├── next-env.d.ts        # 默认生成的next的ts环境引入文件(不需要关注)
├── next.config.js       # next的配置文件
├── package-lock.json    # 项目的package-lock.json
├── package.json         # 项目的package.json
├── pages                # 项目的主要路径目录
│?? ├── _app.tsx         # 每个页面的入口文件
│?? ├── _document.tsx    # 每个页面的文档结构,相当于index.html
│?? ├── api              # 项目的api接口处理(处理服务端接受的请求)
│?? │?? └── hello.ts
│?? └── index.tsx        # 单个页面的入口文件
├── public               # 静态资源目录
│?? ├── favicon.ico
│?? ├── next.svg
│?? ├── thirteen.svg
│?? └── vercel.svg
├── styles               # 样式文件目录
│?? ├── Home.module.css  # 使用module css的方式处理scoped样式
│?? └── globals.css      # 全局的样式必须在 _app.tsx 中引入
└── tsconfig.json        # ts的配置文件

默认生成的项目目录通常为最简单的目录,在实际项目开发中可能并不能满足我们的需求,在稍加改造之后,我们会引入 src 目录作为我们的主要文件目录,其中新增了 components 文件夹存放我们的组件、hooks 文件夹存放自定义的 Hooks、service 文件夹存放一些抽象出来的公共服务、store 文件夹存放状态管理文件、utils 文件夹存放一些公共的方法。

其他的一些配置文件如 Dockerfilepostcss.config.jstailwind.config.js 等都是在项目开发过程中逐步引入的,在项目初始化阶段我们可以不用关注

最终完整的项目目录如下所示,也可以在这里快速查看详细内容:

├── Dockerfile            # Docker镜像配置文件
├── LICENSE               # 项目的LICENSE声明
├── README.md             # 项目介绍文件
├── globals.d.ts          # 自定义的一些全局的ts变量
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js     # postcss配置文件
├── public
│── ├── author.jpg
│──  └── favicon.ico
├── src
│── ├── components/       # 组件存放目录
│── ├── hooks/            # 自定义hooks存放目录
│── ├── pages/            # 页面目录
│── ├── service/          # 公共服务目录
│── ├── store/            # 状态管理文件目录
│── ├── styles/           # 样式文件目录
│── └── utils/            # 公共方法目录
├── tailwind.config.js    # tailwindcss的配置文件
└── tsconfig.json

使用Context管理数据流

由于我们的这个项目复杂度并不高,所以不需要引入 Redux 或者 Mobx 这种完善的状态管理工具,而直接使用了 React 自带的 Context 的方式来进行数据流管理。

Context API是React提供的一种机制,用于在组件之间共享数据,避免了将state从一个组件传递到另一个组件的繁琐过程。

使用Context API时,需要先定义一个Context对象,可以通过React.createContext()函数来创建。然后,在需要共享数据的组件中,使用该Context对象的Provider组件包裹子组件,并将需要共享的数据传递给Provider的value属性。最后,在其他需要访问该数据的组件中,可以通过该Context对象的Consumer组件来获取数据。

例如我们可以通过如下方式来实现 Context 管理数据流:

const MyContext = React.createContext();

function App() {
  const [data, setData] = useState({ name: "Alice", age: 20 });

  // 定义一个更新数据的函数
  const updateData = (newData) => {
    setData(newData);
  };

  return (
    <MyContext.Provider value={{ data, updateData }}>
      <ChildComponent />
    </MyContext.Provider>
  );
}

function ChildComponent() {
  const { data, updateData } = useContext(MyContext);

  const handleClick = () => {
    // 更新数据
    updateData({ name: "Bob", age: 30 });
  };

  return (
    <>
      <div>Name: {data.name}</div>
      <div>Age: {data.age}</div>
      <button onClick={handleClick}>Update Data</button>
    </>
  );
}

在我们的项目的 src/store/ 目录下,创建了以下三个 Context,点击可以查看每个的详细内容:

  • App.tsx —— 用于存放全局的状态,例如侧边栏的收起状态、主题以及对话是否包含上下文等;
  • User.tsx —— 存放用户信息的状态,如头像、描述、昵称等;
  • Chat.tsx —— 存放对话相关的状态,如历史记录、对话记录等。

src/pages/_app.tsx 中我们引入对应的 ContextProvider 并进行嵌套包裹,这样便可以实现所有的页面和组件都可以使用 useContext 获取到 Context 中管理的状态,引入代码如下所示:

// 省略了样式及其他必要内容的引入代码
import AppStoreProvider from "@/store/App";
import UserStoreProvider from "@/store/User";
import ChatStoreProvider from "@/store/Chat";

export default function App({ Component, pageProps }: AppProps) {
    /* 省略了部分代码 */
    ...
    return (
        <AppStoreProvider>
            <UserStoreProvider>
                <ChatStoreProvider>
                    {/* 省略了部分代码 */}
                    ...
                    <Component {...pageProps} />
                    ...
                    {/* 省略了部分代码 */}
                </ChatStoreProvider>
            </UserStoreProvider>
        </AppStoreProvider>
    );
}

前后端请求处理

该项目中前端请求部分并没有使用 axios 这种请求模块,主要还是觉得 axios 略重,并且对我个人来说不够熟悉,因此选择了多年前我开发的并且还一直在使用的前端请求库 web-rest-client。

web-rest-client 是一个用于浏览器中发送RESTful API请求的JavaScript库。相比其他JavaScript库和框架,具有以下优势:

  1. 易用性:web-rest-client提供了简单易用的API,使开发者能够轻松地构建和发送HTTP请求。它的API设计直观清晰,易于理解和使用。

  2. 轻量级:web-rest-client是一个轻量级的JavaScript库,大小只有几KB,不会增加过多的代码负担。

  3. 兼容性:web-rest-client支持所有现代浏览器,包括Chrome、Firefox、Edge等主流浏览器,以及移动端的iOS和Android设备。

  4. 配置灵活:web-rest-client支持各种HTTP请求配置选项,如请求头、查询参数、请求体、认证、超时等,可以根据实际需求进行定制。

  5. 错误处理:web-rest-client提供了良好的错误处理机制,能够捕获并处理请求失败、超时等异常情况,并向开发者提供详细的错误信息。

  6. 可扩展性:web-rest-client的API设计可扩展性强,支持自定义请求拦截器和响应拦截器,可以方便地扩展其功能。

src/service/ 目录中新增了 http.ts 文件,用于处理前端的请求发送,这里我们新增了对话请求的API调用,同时通过自定义插件的方式增加了接口报错时的异常提示,完整代码如下所示:

import { message } from "antd";
import { Client, Options, Response } from "web-rest-client";
import { SendResponseOptions } from "@/service/chatgpt";

class HttpService extends Client {
    constructor() {
        super();

        this.responsePlugins.push((res: Response, next: () => void) => {
            const { status, data, statusText } = res;

            if (status !== 200 && statusText) {
                message.error(`${status} ${statusText}`);
            }

            if ((data as unknown as SendResponseOptions)?.status === "fail") {
                res.status = 999;
            }

            next();
        });
    }

    fetchChatAPIProgress(body: any, options: Omit<Options, "url" | "method">) {
        return this.post("/api/chat-progress", body, options);
    }
}

const http = new HttpService();
export default http;

为了实现对话过程中的逐字输入效果,我们这里不使用传统的获取到所有对话文本再显示的方式,而是通过服务端设置 application/octet-stream 类型的 Content-type, 使得服务端以下载文件的方式返回二进制数据流给到前端。在前端则是通过给 XMLHttpRequest 对象添加 progress 事件来监听服务端返回的数据流并动态更新数据。当然在 web-rest-client 中已经封装好了对 progress 的处理,我们只需要传入 onDownloadProgress 方法的配置即可。

服务端的 Serverless 函数代码如下:

// Next.js API route support: https://nextjs/docs/api-routes/introduction
import { chatReplyProcess, installChatGPT } from "@/service/chatgpt";
import { ConversationRequest } from "@/store/Chat";
import { ChatMessage } from "chatgpt";
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    res.setHeader("Content-type", "application/octet-stream");
    await installChatGPT();

    try {
        const { prompt, options = {} } = req.body as {
            prompt: string;
            options?: ConversationRequest;
        };
        let firstChunk = true;
        await chatReplyProcess(prompt, options, (chat: ChatMessage) => {
            res.write(firstChunk ? JSON.stringify(chat) : `\n${JSON.stringify(chat)}`);
            firstChunk = false;
        });
    } catch (error) {
        res.write(JSON.stringify(error));
    } finally {
        res.end();
    }
}

在前端为了方便处理,请求调用部分我们可以封装成 useChatProgress hooks,完整的代码可以在这里查看。这里给出 onDownloadProgress 方法的处理代码如下所示:

//  useChatProgress中的请求方法
const request = async (index: number, onMessageUpdate?: () => void) => {
    //  这里省略了部分代码
    ...
    
    try {
        controller.current = new AbortController();
        const { signal } = controller.current;
        await http.fetchChatAPIProgress(
            {
                prompt: message,
                options,
            },
            {
                signal,
                onDownloadProgress: (
                    progressEvent: ProgressEvent<XMLHttpRequestEventTarget>
                ) => {
                    const xhr = progressEvent.target;
                    const { responseText } = xhr as XMLHttpRequest;
                    const lastIndex = responseText.lastIndexOf("\n");
                    let chunk = responseText;
                    if (lastIndex !== -1) chunk = responseText.substring(lastIndex);
                    try {
                        const data = JSON.parse(chunk);
                        //  这里省略了更新数据的代码
                        ...
                    } catch (error) {
                        console.error(error);
                    }
                },
            }
        );
    } catch (error: any) {
        //  这里省略了捕获异常之后的处理代码
        ...
    }
};

业务模块开发

这里会简单介绍项目开发过程中几个核心的业务模块及处理方法,详细的具体实现可以查看源码以了解更多。

项目使用了localstorage来做持久化存储。 作为一个轻量级的个人项目,暂时还没有使用 MySQL 或 MongoDB 这种服务端数据库,而是采用前端最熟悉的 localstorage 的方式将用户的所有数据都存在本地。

引入Antd组件库来提升开发效率。 虽然个人感觉在这种小项目中引入 Antd 是有些过重的,而且它还没办法在服务端渲染中使用自定义主题,但是从开发效率出发,个人比较熟悉的 React 的组件库目前也只有它了。
另外 Antd 与 TailwindCSS 共用的时候会出现按钮背景透明的样式冲突问题,只需要在 TailwindCSS 的配置中关闭默认基础样式即可,详细配置可见下个章节。

使用 classnames 库来进行样式类名管理。 classnames 是一个JavaScript库,用于动态生成CSS类名字符串。使用classnames库,开发者可以减少手动编写和拼接CSS类名的工作,使得代码更加清晰简洁、易于维护。

使用dom-to-image库下载图片。 dom-to-image是一个轻量的 JavaScript 库,可以将网页中的任何DOM元素(如div、p、img等)转换为PNG或JPEG格式的图像,并可保存到本地文件系统或直接传输到服务器。

使用原生的 navigator.clipboard.writeText 及 document.execCommand(“copy”) 方法来实现文本的复制。 还是为了保证项目的轻量化,所以直接让 ChatGPT 使用原生的方式实现了一个复制文本的方法,效率也是十分高。

使用 Next 的 404.tsx 和 _error.tsx 来处理异常页面。 当用户输入错误的 URL 或服务端处理请求异常时,我们需要正确的异常页面来进行兜底,这里直接使用 Next 提供的页面加 Antd 的 Result 组件即可。

TailwindCSS处理样式

TailwindCSS 是一个基于原子类的CSS框架,它提供了一组预定义的CSS类,开发者可以通过组合这些类来快速构建出复杂的样式。与其他CSS框架相比,TailwindCSS的特点是更加灵活和可定制。

使用TailwindCSS有以下优势:

  1. 提高开发效率:TailwindCSS提供了一组简洁且易于理解的CSS类,使得开发者可以快速构建出需要的UI界面。开发者不需要自己编写大量的CSS代码,只需通过调用预定义的CSS类即可生成所需的样式。

  2. 代码复用:由于TailwindCSS的CSS类都是原子类,因此可以轻松地将它们组合成更复杂的样式,从而实现重用。开发者可以利用这种方式避免在多个地方编写重复的样式代码。

  3. 可定制性:TailwindCSS提供了大量的配置选项,可以根据实际需求来定制样式。开发者可以通过修改配置文件来改变主题颜色、字体、间距等样式,从而获得更加符合需求的UI界面。

  4. 简化UI设计:由于TailwindCSS提供了一组常用的UI组件样式(如按钮、表格、表单等),开发者可以借助这些样式来简化UI设计过程。这些样式遵循一致的设计风格,可以使得UI界面更加统一和美观。

  5. 优化性能:TailwindCSS的原子类是高度复用的,因此可以减少CSS文件大小,从而提升页面加载速度和性能。

综合来看,TailwindCSS是一种简单易用、高度定制、以性能为导向的CSS框架,它可以为开发者提供更加高效和灵活的CSS开发体验。

同样的对我个人来说,这也是学习和使用 TailwindCSS 的一个很好的机会。

引入TailwindCSS

在项目中通过以下命令来安装 TailwindCSS 所需的依赖:

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest

安装完成后通过初始化命令来完成初始化,该步骤主要用于在项目中生成 postcss.config.jstailwind.config.js 这两个配置文件。通常没有特殊的需求,在初始化完成后,我们直接使用默认的配置即可。执行命令如下:

npx tailwindcss init -p

最后我们只需要在 src/styles/global.css 文件中,添加 TailwindCSS 的样式引入并重新启动项目即可愉快的使用了 TailwindCSS 了。

@tailwind base;
@tailwind components;
@tailwind utilities;

@import "antd/dist/antd.variable.min.css";

html,
body,
#__next {
    height: 100%;
}

响应式处理

TailwindCSS 是一个响应式的CSS框架,它采用了一种基于断点的响应式设计理念。这种设计理念使得开发者可以在不同的屏幕尺寸上自由地组合和调整CSS类,从而实现对不同设备的适配。

具体来说,TailwindCSS提供了多个预定义的断点(如sm、md、lg、xl等),每个断点对应着一个屏幕宽度区间。开发者可以使用TailwindCSS的响应式前缀(如sm、md、lg等)来指定某个CSS类在特定屏幕尺寸下是否生效。例如,可以使用sm:text-lg类来表示只有在屏幕宽度大于等于sm断点时,该文本应该使用更大的字号。

正是由于 TailwindCSS 优秀的响应式设计,使得我们只需要在移动端访问设备时进行少量的代码修改即可完成响应式。这里我们不用考虑不同移动端设备的尺寸,而是通过 User-Agent 匹配及 设备可视区宽度来判断是否是移动端。完整的 useIsMobile Hooks 判断代码如下:

import { useEffect, useState } from "react";
import debounce from "lodash/debounce";

const useIsMobile = (): boolean => {
    const [isMobile, setIsMobile] = useState(false);

    useEffect(() => {
        if (
            /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
                navigator.userAgent
            )
        ) {
            setIsMobile(true);
            return;
        }

        const updateSize = (): void => {
            setIsMobile(window.innerWidth < 768);
        };
        window.addEventListener("resize", debounce(updateSize, 250));
        return (): void => window.removeEventListener("resize", updateSize);
    }, []);

    return isMobile;
};

export default useIsMobile;

以项目中最外层的容器样式为例,在PC端页面中我们需要增加外边距且容器要有灰色边框和阴影,而在移动端的页面中我们并不需要这些样式,那么我们可以通过以下代码去实现,完整代码可以在这里查看:

// 这里省略其他的引入代码
...

import classNames from "classnames";
import useIsMobile from "@/hooks/useIsMobile";

export default function App({ Component, pageProps }: AppProps) {
    const isMobile = useIsMobile();

    return (
        <div
            className={classNames(
                //  这里省略其他的样式代码
                ...
                isMobile ? "p-0" : "p-4"
            )}
        >
            <div
                className={classNames(
                    //  这里省略了其他的样式代码
                    ...
                    isMobile
                        ? ["rounded-none", "shadow-none", "border-none"]
                        : [
                              "border",
                              "rounded-md",
                              "shadow-md",
                              "dark:border-neutral-800",
                          ]
                )}
            >
               {/* 这里省略了Component组件的代码 */}
               ...
            </div>
        </div>
    );
}

最终实现的效果如下图所示:

深色模式

虽然在我们的项目中目前并没有实现深色模式(因为我懒),但使用 TailwindCSS 还是很方便实现这个功能,所以这里加以介绍。

TailwindCSS 支持深色模式,可以根据不同的主题(如浅色和深色)来自动切换颜色方案。它提供了一组内置的类名来实现深色模式的切换,例如 dark:light: 前缀。我们可以将这些类名与我们定义的颜色类名组合,生成深色模式的CSS类名。例如:

<!-- 按钮 -->
<button class="bg-primary text-white dark:bg-dark-primary dark:text-white">Button</button>

不过 TailwindCSS 默认并没有开启深色模式的功能,若我们期望它根据系统的设置自动变更主题色,则需要在 tailwind.config.js 中增加 darkMode: media 的配置,若使用手动变更主题色,则配置项为 darkMode: class,如下所示:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        // 浅色模式下的颜色
        primary: '#1e88e5',
        secondary: '#ffca28',

        // 深色模式下的颜色
        'dark-primary': '#90caf9',
        'dark-secondary': '#ffe082',
      },
      // 深色模式样式配置
      darkMode: 'class', // 可选值:'media'、'class'、false
      // 其他TailwindCSS配置...
    },
  },
};

当我们选择手动配置主题模式,我们可以通过浏览器媒体查询的 prefers-color-scheme 属性来获取系统的主题模式。在模式变更为深色时,手动给 html文档 增加 dark 的类名。这里我们依然将其封装为 src/hooks/useTheme.ts 的 Hooks,其完整代码如下:

import { AppStore } from "@/store/App";
import { useContext, useEffect, useState } from "react";

const useTheme = () => {
    const { theme } = useContext(AppStore);
    const [type, setType] = useState<"light" | "dark">("light");

    useEffect(() => {
        if (theme === "auto") {
            const type = window.matchMedia("(prefers-color-scheme: dark)").matches
                ? "dark"
                : "light";
            setType(type);
            return;
        }

        setType(theme);
    }, [theme]);

    useEffect(() => {
        if (type === "dark") {
            document.documentElement.classList.add("dark");
        } else {
            document.documentElement.classList.remove("dark");
        }
    }, [type]);

    return type;
};

export default useTheme;

chatgpt库调用API

chatgpt是一个用于在Node.js环境中与ChatGPT模型进行交互的JavaScript库。它提供了一组API,使得开发者可以方便地在Node.js环境中使用ChatGPT模型,从而实现自然语言处理功能。

具体来说,chatgpt库提供了sendMessage函数,可以向ChatGPT模型发送消息,并返回模型生成的响应消息。开发者可以利用这个函数实现多种自然语言处理应用,如聊天机器人、语音助手等。

它提供了以下配置项:

  • modelName —— 必填项,指定要使用的ChatGPT模型名称。可以从OpenAI或Hugging Face等平台下载或训练自己的模型。
  • api_key —— 必填项,指定使用的API密钥,用于与ChatGPT API服务器进行通信。
  • temperature —— 可选项,设置生成响应的温度值,范围为0到1,默认值为1。较高的温度会导致生成的响应更加随机和多样化。
  • maxTokens —— 可选项,设置生成响应的最大标记数,默认值为50。如果生成的响应超过了这个标记数,则会被截断。
  • stop —— 可选项,设置生成响应的停止词列表,以字符串数组的形式指定。当模型生成的响应中包含任何停止词时,会停止生成响应。

在我们的项目中,新增了 src/service/chatgpt.ts 的服务模块,该模块提供了几个不同的方法,分别用于初始化配置、发送消息给 OpenAI以及发送数据给前端。 其中初始化配置部分包含了读取环境变量,并根据不同的变量去初始化不同的 chatgpt实例,另外考虑到国内的情况,也新增了代理的方式来保证能够访问到墙外的API。

这里给出 installChatGPT 方法的实现,具体逻辑在下文注释中,完整代码可以在这里查看。

export const installChatGPT = async () => {
    //  在severless函数会重复执行install,所以避免重复初始化实例
    if (api) {
        return;
    }

    //  必须得填写一个API的token,否则就报错
    if (!process.env.OPENAI_API_KEY && !process.env.OPENAI_ACCESS_TOKEN)
        throw new Error("Missing OPENAI_API_KEY or OPENAI_ACCESS_TOKEN environment variable");

    // More Info: https://github/transitive-bullshit/chatgpt-api
    //  如果是调用官方的API接口,则需要API_KEY
    if (process.env.OPENAI_API_KEY) {
        const OPENAI_API_MODEL = process.env.OPENAI_API_MODEL;
        
        //  模型选择,默认是gpt-3.5-turbo
        const model =
            typeof OPENAI_API_MODEL === "string" && OPENAI_API_MODEL.length > 0
                ? OPENAI_API_MODEL
                : "gpt-3.5-turbo";

        const options: ChatGPTAPIOptions = {
            apiKey: process.env.OPENAI_API_KEY,
            completionParams: { model },
            debug: false,
        };

        //  API请求地址,默认的就是官方提供的,一般不需要修改
        if (process.env.OPENAI_API_BASE_URL && process.env.OPENAI_API_BASE_URL.trim().length > 0)
            options.apiBaseUrl = process.env.OPENAI_API_BASE_URL;

        //  新增的代理模式,使用socks-proxy-agent库结合node-fetch库实现
        if (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT) {
            const agent = new SocksProxyAgent({
                hostname: process.env.SOCKS_PROXY_HOST,
                port: process.env.SOCKS_PROXY_PORT,
            });
            // @ts-ignore
            options.fetch = (url, options) => fetch(url, { agent, ...options });
        }

        api = new ChatGPTAPI({ ...options });
        apiModel = "ChatGPTAPI";
    } else {
        //  使用非官方的代理方式来访问,如Azure云服务等透传请求
        const options: ChatGPTUnofficialProxyAPIOptions = {
            accessToken: process.env.OPENAI_ACCESS_TOKEN,
            debug: false,
        };

        //  跟上面一样的
        if (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT) {
            const agent = new SocksProxyAgent({
                hostname: process.env.SOCKS_PROXY_HOST,
                port: process.env.SOCKS_PROXY_PORT,
            });
            // @ts-ignore
            options.fetch = (url, options) => fetch(url, { agent, ...options });
        }

        //  第三方代理的接口
        if (process.env.API_REVERSE_PROXY)
            options.apiReverseProxyUrl = process.env.API_REVERSE_PROXY;

        api = new ChatGPTUnofficialProxyAPI({ ...options });
        apiModel = "ChatGPTUnofficialProxyAPI";
    }
};

Railway一键部署

对于 NextJS 项目,官方提供了很方便的使用 npm run build && npm run start 的方式来构建及部署项目,但是因为我们没有自己的服务器,所以期望使用一些一键部署的云服务来进行。

在使用 Railway 之前,我还尝试了 VercelDenoZeabur等一些其他的云服务。VercelNextJS 官方成员做的项目,所以对于 Next 项目的支持非常好,而且提供了很便利和个性化的功能,但问题在于 Vercel 的免费版限制了 API 的响应时长最大为 10s,就导致我们的接口还没响应完成就直接结束了;Deno 的话我其实有点没太用明白,感觉想要成功部署还需要对项目做一些改造,所以尝试之后就放弃了;Zeabur 看了一下是今年新出的创业团队的项目,是国内的几位同仁做的,界面挺炫酷,不过功能上还是要弱一些,没办法支持自定义 install 命令。

以上这几个都是很不错的云服务平台,非常适合部署一些小的以及创业团队的项目,而且都有免费的额度,有兴趣的同学可以去体验一下。

回到正题。

Railway 是一个基于云平台的开发工具,旨在为开发者提供方便、高效和可靠的应用部署和管理服务。它支持多种编程语言和框架,如Node.js、Python、Ruby等,可以轻松地将应用程序部署到云端并进行自动化管理。

使用Railway,开发者可以省去繁琐的服务器配置、数据库设置和应用部署等步骤,专注于应用程序的开发和迭代。同时,Railway还提供了丰富的功能和工具,如日志监控、性能分析、CI/CD集成等,可帮助开发者更好地管理和优化应用程序。

这里我们不详细介绍 Railway 的过多功能(那样可能要讲好久好久??),只介绍在我们的项目中如何使用 nixpacks 以及 Docker 两种方式来进行部署。

另外最最最重要的一点,使用 Railway 部署时,由于其服务器在国外,所以可以直接访问国外的服务,并且不会被第三方服务认为是的请求,所以不需要?就可以访问 OpenAI。同时 Railway 提供的地址在?内也是可以正常访问的!

nixpacks

进入 railway.app/ ,点击按钮「Start a New Project」,选择从 Github 部署,也可以选择从模板部署或者创建空的项目。之后选择 Ghitub 的项目仓库,确定后 Railway 将自动完成以下几个步骤:创建项目、在项目中新增 Service、编译与部署 Github 项目到 Service 中。此时会进入到项目的 Service 配置界面,如下所示:

当我们的项目中没有 Dockerfile 文件时,Railway 默认会使用 nixpacks 的方式进行项目部署,在 ServiceSettings 选项卡中可以看到当前项目的部署方式。(由于我的项目是用 Docker 方式部署的,所以另找了一个项目作为示例

nixpacks 是一个方便、高效和可靠的Railway应用程序构建和部署工具,可以帮助开发者轻松地构建和部署各种类型的应用程序。使用 nixpacks,开发者可以专注于应用程序的开发和迭代,从而提高开发效率和开发体验。我们这里不会深入 nixpacks 的用法和原理,主要介绍如何通过网页交互进行部署。

通常我们开启 nixpacks 方式部署后,nixpacks 会自动分析项目类型,并找到对应的项目构建及启动命令。如在前端及 Node 项目中,nixpacks 会使用 package.json 中的 build去执行项目构建以及使用 start 命令去启动构建完成后的项目。

当我们的项目的构建及启动命令不是 buildstart 时,我们可以在 Settings 面板中找到 DeployBuild 模块,在这两个模块下,可以通过自定义的方式来修改对应的命令。

正常在项目执行完 buildstart 之后,便部署成功了,Railway 会为其生成一个专属的域名,我们可以直接通过这个域名访问项目,也可以绑定我们自己的域名。域名的查看及自定义都在 Setting 选项下的 Domains 模块。

Railway 上,可以通过以下步骤来绑定自定义域名:

  1. 在您的 DNS 服务提供商处添加域名记录:在您的 DNS 服务提供商处添加一条 CNAME 记录,将您的自定义域名指向 Railway 分配给您应用程序的 subdomain(可以在 Railway 应用程序控制台中找到)。例如,如果您的应用程序 subdomain 为 myapp-1234abcd.railway.app,则需要将您的自定义域名 www.example 指向 myapp-1234abcd.railway.app
  2. 在 Railway 应用程序控制台中添加自定义域名:在 Railway 应用程序控制台中,转到“Settings”选项卡,然后单击“Domains”选项卡。在该页面上,单击“Add Domain”按钮,并输入您的自定义域名。然后,单击“Save Changes”按钮保存更改。
  3. 验证您的域名:Railway 将自动验证您的域名是否已正确配置。验证成功后,您的自定义域名将显示为“Active”状态。

完成上述步骤后,就可以使用自定义域名访问应用程序了。请注意,DNS 记录可能需要一些时间来生效,通常需要几分钟或几个小时的时间。

另外在项目创建时以及在 Service 的 Variables 面板中我们都可以添加项目运行需要的环境变量,这里不再详细介绍。

Docker

nixpacks 帮我们做了很多工作,但封装程度越高,就意味着我们可以自定义的内容就越少,因此本项目最终也是采用了 Railway 提供的第二种部署方式 —— Docker部署

当使用 Docker 方式部署时,我们依然需要先在平台上创建项目和服务(当然也可以用 CLI 工具),之后便只需要专注于书写项目的 Dockerfile 文件即可。

在项目的根目录创建 Dockerfile 文件,并填入内容如下:

# Install dependencies and run build
FROM node:alpine AS deps
WORKDIR /app

COPY package.json package-lock.json /app/
RUN npm install --legacy-peer-deps

COPY . /app
RUN npm run build

# Production image, copy all the files and run next
FROM node:alpine AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

COPY --from=deps /app/public ./public
COPY --from=deps --chown=nextjs:nodejs /app/.next ./.next
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package.json ./package.json

USER nextjs

EXPOSE 3000

ENV PORT 3000

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs/telemetry
# Uncomment the following line in case you want to disable telemetry.
# ENV NEXT_TELEMETRY_DISABLED 1

CMD ["node_modules/.bin/next", "start"]

在上述的 Dockerfile 文件中我们基于 node 的基础镜像来制作,在镜像中创建 /app 目录作为我们的项目目录,复制 package.json 文件到镜像中后执行安装依赖的命令,之后将项目中的文件全部拷贝进镜像并执行构建。在下一个阶段中我们新建了单独的用户并从上个阶段复制构建产物到 /app 目录下,最后设置镜像使用 3000 端口并规定启动命令为 node_modules/.bin/next start

当我们将 Dockerfile 文件提交到项目仓库时,会自动触发 Railway 中的构建及部署,构建部署流程即为镜像的制作和容器启动流程。需要注意的是,镜像容器启动时会将端口映射为 80 端口,所以使用域名访问时不需要再指定端口。


至此,我们已经实现从零开发并自动化部署我们的个人专属 ChatGPT!再加上 ChatGPT 提供的免费的 API Secret Key,终极白嫖的感觉针不戳 ?? ~

彩蛋

彩蛋一:本项目的部分代码是由 ChatGPT 生成的。

彩蛋二:本文的部分内容是由 ChatGPT 生成的。

更多推荐

搭建个人专属ChatGPT(零成本且不需要XX)