今年的情人節,給心愛的她一個不一樣的禮物吧

willin發表於2022-02-02

今天是 2022 年 2 月 2 日,距離今年的情人節只有不到兩週的時間了。

給大家隆重介紹一個網站,憨憨.我愛你

參考示例網站:


申請流程

開啟官網首頁: 憨憨.我愛你

(由於該網站資料庫使用的是 PlanetScale 免費服務,位於美東,所以訪問可能會稍微有點慢。請耐心等待。)

請新增圖片描述

使用 Authing 賬號登入(可以手機、郵箱註冊,Github 賬號登入,或者微信小程式掃碼,後續還會新增更多登入方式)。

請新增圖片描述

可以分別點選進入域名申請和郵箱申請。

域名申請

域名申請介面如下:

請新增圖片描述

支援的繫結方式有三種:

  • CNAME: 可以使用 Github Pages、 Netlify、 Gitee、 Coding.net、 Cloudflare 等提供託管服務的平臺

    • 值參考: willin.github.io
    • 注意: 不支援 Vercel ,因為 Vercel 預設情況下並不支援繫結二級域名(除非所有權在你個人名下)
  • A: IPv4 需要自己搭建伺服器,並進行繫結

    • 值參考: 1.2.3.4 (你伺服器的 ip 地址)
  • AAAA: IPv6 需要自己搭建伺服器,並進行繫結

    • 不做表述,不太推薦非專業人士選擇
    • 如果你需要同時繫結 IPv4 和 IPv6 的話,建議建議註冊 A 型別,然後 ISSUE 或郵件聯絡我配合處理

其中還有一項 Proxied(CDN),如果不知道作用,可以嘗試開啟或關閉來測試。

郵箱申請

域名申請介面如下:

請新增圖片描述

目前使用了 Cloudflare 的郵件轉發服務,但由於暫不支援 IDN 域名,所以可以提前搶注,第一時間擁有。

其他說明

如需幫助

歡迎在 Github 上關注我: willin ,如果在為心愛的她準備禮物時遇到問題,可以為你免費提供技術諮詢。

還想要其他的域名

  • js.cool (在多次協商後,目前已經支援 Vercel 繫結)
  • log.lu (敬請期待)

感覺慷慨

躍躍欲試

或許你也有很多想法,想要實現。您可以:

  • 使用 Authing 快速整合開發你自己的應用
  • Fork 本專案原始碼(完全開源),並提供你自己的域名服務

  • 在 Github 上對本專案進行完善和優化


開源

接下來,開始一個重要的環節。俗話說,授人以魚不如授人以漁。我將 憨憨.我愛你 的原始碼 進行開源,並詳細講解一下設計與實現的全部過程。

設計

這個專案大概花了我 3 個小時左右完成。為了揚長避短,我使用了 UI 框架,所以就沒有額外的 UI 設計了,直接用幾個基礎元件快速上手該專案。

技術選型

首先,第一步是技術選型。因為我要提供的是一項免費的服務,所以儘量也選擇一些免費的服務商,及一些相關的技術棧。

服務商的選擇:

  • Cloudflare: 提供免費的域名解析、CDN 加速以及開放的介面
  • Vercel: 面向個人的免費應用託管,支援 Node.js 環境,使用 Next.js 框架
  • PlanetScale: 具有一定免費額度的雲端 MySQL 服務
  • Prisma: Cloud Studio 管理資料庫

其實,本來是想使用 Cloudflare 全家桶的,就是用 Cloudflare Pages (靜態網站) + Cloudflare Workers (Serverless 方法執行)及 KV (鍵值對儲存),但是由於時間和精力的限制,所以就採用了更簡單快捷的實現方式。

技術棧:

  • Typescript: 雖然我喜歡用更少的程式碼做更多的事情,但 TS 帶給我更高效的團隊協作舞臺
  • Next.js: 一個全棧框架(前端使用 React,後端類似於 http 模組和 Express),支援 SSR(伺服器端渲染)和 SSG(靜態站點生成)

  • Prisma: 下一代的 ORM 框架,支援多種資料庫(本專案使用為 MySQL)和資料庫遷移(Migration)
  • Tailwind CSS: 下一代的 CSS 框架,實用第一

    • Daisy UI:封裝了一些 UI 樣式元件

資料庫設計

由於我用的是 Authing 使用者整合,所以省去了使用者表的設計和使用者相關介面的設計。

// 域名型別
enum DomainType {
  A
  AAAA
  CNAME
}

// 稽核狀態
enum Status {
  // 待稽核
  PENDING
  // 啟用
  ACTIVE
  // 已刪除
  DELETED
  // 被管理員禁用
  BANNED
}

// 域名記錄表
model Domains {
  // Cloudflare 域名記錄的 ID,同時作為表主鍵 id
  id        String      @id @default(cuid()) @db.VarChar(32)
  // 自增 id,沒有什麼實際意義,只是為了減少查詢(畢竟有呼叫配額限制),實際專案中不推薦自增主鍵及自增 id 使用
  no        Int         @default(autoincrement()) @db.UnsignedInt
  name      String      @db.VarChar(255)
  punycode  String      @db.VarChar(255)
  type      DomainType  @default(CNAME)
  content   String      @default("") @db.VarChar(255)
  proxied   Boolean     @default(true)
  // Authing 的使用者 id
  user      String      @default("") @db.VarChar(32)
  status    Status      @default(ACTIVE)
  createdAt DateTime    @default(now())
  updatedAt DateTime    @updatedAt

  @@index([no])
  @@index([name, punycode])
  @@index([user, status, createdAt])
}

// 郵箱表
model Emails {
  // 由於 Cloudflare 郵箱還沒有提供開放介面,所以需要人工稽核和操作,這裡會填入預設的 cuid 作為主鍵 id
  id        String      @id @default(cuid()) @db.VarChar(32)
  // 自增 id,沒有什麼實際意義,只是為了減少查詢(畢竟有呼叫配額限制),實際專案中不推薦自增主鍵及自增 id 使用
  no        Int         @default(autoincrement()) @db.UnsignedInt
  name      String      @db.VarChar(255)
  punycode  String      @db.VarChar(255)
  content   String      @default("") @db.VarChar(255)
  user      String      @default("") @db.VarChar(32)
  status    Status      @default(PENDING)
  createdAt DateTime    @default(now())
  updatedAt DateTime    @updatedAt

  @@index([no])
  @@index([name, punycode])
  @@index([user, status, createdAt])
}

非常簡單,參考註釋說明。另外,我本來是打算只存一個名稱的,但由於會重複註冊,比如說我註冊了一箇中文名老王,你又註冊了一個對應的 punycode 程式碼名 xn--qbyt9x,就會衝突,所以索性(偷懶)都存下吧。

技術準備

先把 Next.js 網站框架搭建起來,部署到 Vercel 上進行測試。可以再加上 Tailwind CSS 和 Authing SSO 整合。第一步準備工作就算完成了。

介面設計

為了快速(偷懶)實現,我分別建立了增刪改查四個介面。

查詢介面:

graph TD
    Start1(Start)
    --> |檢查域名是否存在| check1{是否登入}
    --> |F| fail1[失敗]
    --> End1(End)
    check1 --> |T| check12{檢查是否為保留域名}
    --> |T| fail1
    check12 --> |F| check13{檢查資料庫重複}
    --> |T| fail1
    check13 --> |F| success1[允許註冊]
    --> End1

建立介面:

graph TD
    Start2(Start)
    --> |建立域名| check2{是否登入}
    --> |F| fail2[失敗]
    --> End2(End)
    check2 --> |T| check22{檢查是否為保留域名}
    --> |T| fail2
    check22 --> |F| check23{使用者是否已經註冊域名}
    --> |T| fail2
    check23 --> |F| check24{檢查資料庫重複}
    --> |T| fail2
    check24 --> |F| success2[註冊]
    --> End2

資料庫查詢使用者是否已經註冊域名和是否存在同名可以用一次查詢完成,這裡為了提高查詢效能進行了拆分。

修改介面:

graph TD
    Start3(Start)
    --> |檢查域名是否存在| check3{是否登入}
    --> |F| fail3[失敗]
    --> End3(End)
    check3 --> |T| check32{修改 id 和 使用者匹配的記錄}
    --> |F 修改記錄數 0| fail3
    check32 --> |T| success3[修改成功]
    --> End3

刪除介面與修改介面同。郵箱介面與域名類似,不再贅述。

程式碼實現

封裝 Cloudflare SDK

當然也有現成的庫可以直接用,但是因為沒幾行程式碼,我就自己手擼了。

import { Domains } from '@prisma/client';
import { CfAPIToken, CfZoneId } from '../config';

const BASE_URL = 'https://api.cloudflare.com/client/v4';

export type CFResult = {
  success: boolean;
  result: {
    id: string;
  };
};

const headers = {
  Authorization: `Bearer ${CfAPIToken}`,
  'Content-Type': 'application/json'
};

export const createDomain = async (
  form: Pick<Domains, 'name' | 'content' | 'type' | 'proxied'>
): Promise<string> => {
  const res = await fetch(`${BASE_URL}/zones/${CfZoneId}/dns_records`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ ...form, ttl: 1 })
  });
  const data = (await res.json()) as CFResult;
  if (data.success) {
    return data.result.id;
  }
  return '';
};

export const updateDomain = async (
  id: string,
  form: Pick<Domains, 'name' | 'content' | 'type' | 'proxied'>
): Promise<boolean> => {
  const res = await fetch(`${BASE_URL}/zones/${CfZoneId}/dns_records/${id}`, {
    method: 'PATCH',
    headers,
    body: JSON.stringify({ ...form, ttl: 1 })
  });
  const data = (await res.json()) as CFResult;
  console.error(data);
  return data.success;
};

export const deleteDomain = async (id: string): Promise<boolean> => {
  const res = await fetch(`${BASE_URL}/zones/${CfZoneId}/dns_records/${id}`, {
    method: 'DELETE',
    headers
  });
  const data = (await res.json()) as CFResult;
  return !!data.result.id;
};

封裝校驗工具類

需要有一定的正則基礎,如果你需要線上除錯工具,可以訪問: regexper.js.cool

域名(CNAME)校驗正則:

/^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/;

郵箱校驗正則:

/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;

IPv4 校驗正則:

/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;

IPv6 校驗正則:

/^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:)))(%.+)?$/;

頁面請求封裝

以域名註冊提交為例:

async function submit(e: SyntheticEvent) {
  e.preventDefault();
  // 因為我 Vue、 React 都會用,且用的都比較少
  // 所以獲取表單資料,我用的是 Vanilla JS 方式,通用性更高
  // 如果你不熟悉,可以用 React 的方式
  const target = e.currentTarget as typeof e.currentTarget & {
    type: { value: DomainType };
    content: { value: string };
    proxied: { checked: boolean };
  };
  const type = target.type.value;
  const content = target.content.value;
  if (!validateContent(type, content)) {
    return;
  }
  const form = {
    type,
    content,
    proxied: target.proxied.checked,
    name,
    punycode: toASCII(name)
  };
  // 我建議對 Fetch 進行封裝,為了追求效率(偷懶),我就沒有做
  const res = await fetch(`/api/domain/create`, {
    method: 'POST',
    body: JSON.stringify(form),
    headers: {
      'content-type': 'application/json'
    }
  });
  // 所以像這樣的處理,就非常不優雅,而且還可以統一封裝,將錯誤提示使用通知條元件之類的
  const result = (await res.json()) as { success: boolean; id: string };
  if (result.success) {
    router.reload();
  } else {
    alert('出錯啦!請稍後重試');
  }
}

可複用的程式碼可以進行封裝。參考軟體工程的思想:高內聚、低耦合。我這裡舉的是一個較為反面的教材,程式碼臃腫、可讀性低。

注意點

  • 由於 Tailwind CSS 3 採用了全新的 JIT 機制,purgecss 不再需要
  • 關注 React 效能,如 useState 之類的 Hooks,儘量放在頁面級別,不要放在元件級別(尤其是會迴圈生成的元件)
  • 使用 useMemodebounce 之類的方式進行快取、防抖、限流,以提升應用效能
  • 使用 Next.js 框架(或普通 React 應用)時,大部分情況下,多瞭解 swr 及其內部的一些核心思想會很有裨益

剩下的程式碼部分就枯燥且簡單了。


差不多就講這麼多吧。記得分享哦!

willin | 憨憨.我愛你 | 專案原始碼

相關文章