專案完成小結

2022-12-07 06:00:47

前言

最近有個專案到一段落,做個小結記錄。

內容可能會多次補充,在部落格上實時更新哈~

如果是在公眾號閱讀這篇文章,可以點選「檢視原文」存取最新版本~

這個專案是前後端分離,後端為了快,依然用我的DjangoStarter框架。前端一開始是小程式,後面突然換成公眾號H5的形式,還好我用了Taro,大差不差。

不過Taro目前沒啥好用成熟的元件庫,前一個專案本來用著Taroify,不過用了一半專案還沒做完,Taroify的作者就跑路不維護了~ 雖然但是,還是能用,把舊專案的一些程式碼複用一下,也不是不行。

總體的開發體驗就是很一般,雖說React寫前端舒服多了,但元件庫實在是拉胯… 如果下個專案依然要用Taro的話,估計得試試新出的NutUI-React了。

說回正題,這次我從Web開發、部署這幾方面對這個專案做個小總結。

後端

後端用DjangoStarter模板,自從我上次升級了v2版本之後,還沒實戰過,這次專案上使用了,還是穩得一批,(所以點star的同學可以放心用哈~)

之前oauth部分只有企業微信,微信登入還是todo,這次因為接入公眾號,我順手也把微信登入做了,其實跟企業微信基本沒啥區別。

"雙標"的ModelViewSet

drf的ModelViewSet,可以快速生成crud介面,不過預設許可權控制很粗糙,只能選擇三種:

  • 已登入可存取
  • 管理員可存取
  • 任何人可存取

但正常的場景是,假設有個文章介面,使用者只能管理自己寫的文章,管理員可以存取全部文章。也就是要對不同角色的使用者區別對待~

要實現的話可以這樣,重寫 ModelViewSetget_queryset 方法,根據使用者的身份來生成對應 queryset

def get_queryset(self):
  user: User = self.request.user
  if user.is_superuser:
    return super().get_queryset()
  else:
    return super().get_queryset().filter(user=user)

然後再新增和更新的時候也要修改一下

比如重寫一下 create 方法,最前面加上當前使用者的id

def create(self, request: Request, *args, **kwargs):
  request.data['user'] = request.user.id
  # 後面程式碼就省略了

Model Field 擴充套件

本次用了兩個擴充套件

  • tinymce 的 HTMLField : 用於富文字編輯,也就是前面說的文章功能
  • MultiSelectField :用於多選欄位(雖然Django+PgSql可以儲存列表資料,但跟這好像倆回事)

tinymce之前的文章有介紹過,我還封裝了一個 contrib 包,後面有空整合到DjangoStarter裡面

MultiSelectField使用也簡單,可以把一個 Choices 作為欄位的值,在Django Admin裡面,表現為一個多選列表,編輯和使用都比較方便~

一些架構設計的問題

本來在做DjangoStarter v2版本的時候,我把相關的程式碼都放在 django_starter 包裡,就是為了開發者不需要去修改這部分程式碼,這樣在DjangoStarter版本有更新的時候,可以直接覆蓋升級。

但我又把 oauth 和 UserProfile (使用者資訊)所在的 auth 這兩個app都放在了 django_starter/contrib 包裡面。

但往往一個專案中,難免會對使用者資訊做一些擴充套件,這樣就得修改到這個 django_starter 下面的程式碼,這不符合設計規範~

這部分也是我在v2版本設計的問題,看來可能要把這個使用者資訊相關的程式碼都放回到 apps 下面,讓使用者(開發者)自行決定這部分程式碼是否使用。

前端

前端使用React+TypeScript,開發體驗還可以,儘管之前經常吐槽TypeScript,但熟悉之後還是能愉快使用的,畢竟和C#同一個作者,質量有保障~

雖說Taro坑很多,元件庫質量也不高

但… 沒有的元件,就自己造輪子!

好吧,在造輪子這件事上,我把自己坑了一下… 我自己做了個日曆元件,不得不說,日曆元件確實有點小複雜,專案開發過程中,這元件就出了兩次坑爹的bug,花了我不少時間折騰~

說回來,就用Taro提供的最基本的 view 元件,再配合scss,可以說元件庫裡缺什麼,自己造什麼,雖然我不是專門做前端的,樣式寫得很菜,但… 勉強能看吧

我感覺React用上手了比vue舒服一些(非引戰),可能跟我之前經常用Flutter習慣了宣告式UI有關~

掏出幾個常用的hook(useEffect / useRef / useRouter),開發體驗很絲滑。

這次我還多學了一個 useLayoutEffect,用來解決頁面閃爍。

全域性狀態管理沒用redux,改用輕量級mobx,舒服~ 不過除了使用者管理,其他的基本上可以用路由傳參解決,全域性狀態用得很少。

路由管理

好訊息,這次我終於沒有手寫路由地址了

終於搞了個 RouterMap

export const RouterMap = {
  announcementDetail: 'pages/announcement/detail',
  announcementList: 'pages/announcement/index',
  home: '/pages/index/index',
  feedback: 'pages/user/feedback',
  login: '/pages/user/login',
  order: 'pages/user/order'
}

需要跳轉的時候就

Taro.navigateTo({url: RouterMap.login})

不過在必須登入才可以存取的頁面上,我還是用最原始的判斷跳轉,很不優雅

useEffect(() => {
  if (!myUserStore.isLogin) {
    Taro.redirectTo({url: RouterMap.login})
    return
  }
}, [])

看了「前端帶師」的 remax-router,對路由做了hack,直接在框架路由處做攔截,真羨慕啊,等會學會這個操作我也要這樣做。

多寫元件

雖然我自己造輪子埋了不少坑,但還是鼓勵多用元件,現代前端就是元件化開發嘛,都寫在一個頁面也太醜了,都給我拆成元件!

於是,我的src目錄下就有倆放元件的目錄,一個是 components ,一個是 ui

前者放只在本專案內用的元件,後者放通用元件,可複用的那種,以後有空做成NPM包的那種。

元件間的通訊很方便,父元件向子元件傳遞,直接props傳值;子到父,直接在props裡定義個事件就行了。

比如我這個日曆元件

export interface CalendarSmallProps extends ViewProps {
  days: Array<DayPlan>
  value?: Date

  onChange?(value: DoctorDayPlan): void
}

父元件使用的時候

<CalendarSmall days={days} value={currentDate} onChange={handleDayChange}/>

日曆元件向父元件傳值,也就是觸發事件

function setDay(item: DoctorDayPlan) {
  setValue(item.date)
  props.onChange?.(item)
}

咱就是說,這個 xxx?.() 的語法真是妙 (連「前端帶師(coppy)」都讚不絕口,能不妙嗎?)

然後每個元件建立個目錄,比如這個日曆元件,我放在 ui/calendar_small 下。倆個檔案:

  • index.tsx :主要程式碼
  • index.scss :樣式

然後在 ui 目錄下再來個 index.ts

裡面匯出一下

export * from './calendar_small'

這樣在使用的時候只要 import {CalendarSmall} from "@/ui"; 即可,方便得很啊!

部署

前面寫了那麼多,我都差點忘了部署才是本次專案重點想記錄的。

前段時間我買了個新域名 dealiaxy.com,新專案也搞了個新的伺服器,這次部署想實現的效果是 *.dealiaxy.com 泛域名解析,且全部走HTTPS。

之前看同學部落格的時候發現有個叫swag的映象,把 Let's Encrypt 都折騰設定好了,開箱即用,這次來試試看。

使用swag設定HTTPS

因為這是我第一次用swag映象部署 Let's Encrypt 的泛域名HTTPS,遇到挺多坑的,也查了很多資料,最終完美搞定~

很多時候雖然檔案很齊備,但因為各種條件不一致,很難一下子搞起來。

首先在域名控制檯新增A記錄的解析,把 @* 都指向這臺伺服器,然後準備個空目錄來部署swag容器。

docker 部署

繼續用docker-compose,有幾個關鍵設定。

  • Let's Encrypt 有多種驗證方式,因為我要用泛域名證書,所以設定 VALIDATION 為 dns 方式
  • 時區 TZ 設定為 Asia/Shanghai
  • 子域名 SUBDOMAINS 設定為 wildcard (萬用字元)
  • DNSPLUGIN 是DNS提供商,是設定重點,後面說
  • 掛載一下 /config 目錄,後面swag跑起來之後需要在裡面設定域名和網站資訊
version: "2"
services:
  swag:
    image: linuxserver/swag
    container_name: swag
    cap_add:
      - NET_ADMIN
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Shanghai
      - URL=dealiaxy.com
      - SUBDOMAINS=wildcard
      - VALIDATION=dns
      - DNSPLUGIN=cloudflare
    volumes:
      - ./config:/config
    ports:
      - 443:443
      - 80:80
    restart: unless-stopped
networks:
  default:
    name: swag

DNSPLUGIN 設定

swag支援很多DNS提供商,比如阿里雲、騰訊雲、cloudflare這些。具體的可以看 config/dns-conf 裡面的設定。

我這個域名是國外買的,恰好那家服務商也沒有在swag的支援列表裡面,一開始還有點暈頭轉向不知道咋辦,後面看到swag支援阿里和騰訊的dnspod,於是我在阿里DNS上看了一下,可以設定解析,瞬間悟了,域名在哪買的不重要,域名的DNS提供商可以隨便換的。

根據阿里DNS的指引,在域名控制檯裡面把Name Server改成阿里的 ns1.alidns.comns2.alidns.com 就行了。

然後在阿里雲的控制檯裡生成一下 access_key 和 secret,編輯 config/dns-conf/aliyun.ini 放進去,再啟動swag容器就行了。

tips:阿里雲DNS需要域名有備案才提供解析,未備案的話慎用~ 可以試試Cloudflare,據說很好用。

用其他的DNS提供商同理,操作很類似。

docker 網路設定

docker容器直接預設是不能直接連線的,所以反向代理也就無從說起。

swag和後端是倆不同的docker容器,要能互相連線,得先加入同一docker網路才行。

推薦portainer這個工具,可以很方便管理docker~

使用docker-compose啟動swag,會自動生成一個swag_default的網路,拿這個來用就行了,我先把它改名成swag,方便記憶。

然後再修改一下後端的docker-compose設定,增加網路設定

networks:
  swag:
    external:
      name: swag

然後,我這個docker-compose裡有redis和django兩個容器,只有django需要加入swag,所以在django下面設定一下網路

web:
	networks:
		- swag
		- default

這樣就行了~ (當然我後面還要再改一下,這樣寫只是方便理解)

反向代理設定

泛域名證書設定搞定了,接下來可以設定網站

靜態檔案放在 config/www 裡面

後端需要做反向代理,設定在 config/nginx/proxy-confs 裡面

這裡面有個比較難受的地方,swag預設提供了一堆反向代理的模板(檔名 .example 結尾),這個目錄一開啟裡面一堆檔案,很影響我找到我已經設定好的,解決辦法是 ls 的時候用正則匹配一下檔名。

ll | grep .conf$

這樣就只顯示以 .conf 結尾的檔案了。

假設我的應用域名是 app1.dealiaxy.com ,那組態檔名就是 app1.subdomain.conf

附上我的反向代理設定:

server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name app1.*;

    include /config/nginx/ssl.conf;

    client_max_body_size 0;

    # enable for ldap auth, fill in ldap details in ldap.conf
    #include /config/nginx/ldap.conf;

    # enable for Authelia
    #include /config/nginx/authelia-server.conf;

    location / {
        # enable the next two lines for http auth
        #auth_basic "Restricted";
        #auth_basic_user_file /config/nginx/.htpasswd;

        # enable the next two lines for ldap auth
        #auth_request /auth;
        #error_page 401 =200 /ldaplogin;

        # enable for Authelia
        #include /config/nginx/authelia-location.conf;

        include /config/nginx/proxy.conf;
        resolver 127.0.0.11 valid=30s;
        set $upstream_app app1_nginx;
        set $upstream_port 8001;
        set $upstream_proto http;
        proxy_pass $upstream_proto://$upstream_app:$upstream_port;

    }
}

主要就看這幾行:

set $upstream_app app1_nginx; # 容器名稱
set $upstream_port 8001; # 容器埠,容器裡面開啟的埠,不是通過 ports 對映的
set $upstream_proto http; # 協定,還有其他比如 uwsgi, https 之類的

再看看接下來的Django部署,就會一目瞭然了~

Django部署

Django部署依然是用之前很熟悉的docker部署,不過這次我又做了一些修改。

之前是一個nginx服務直接裝在系統上,若干個docker容器跑服務,這種情況下每個容器只需要提供web應用功能,不用管靜態檔案,直接在nginx裡面設定靜態檔案就行了。

但是現在,nginx也裝進了docker(swag),那就沒辦法隨意存取到整個系統的檔案,如果每增加一個應用,都去掛載一個新的volume到swag裡,那也太折騰了。

所以我選擇在Django的docker-compose裡整合nginx。

docker-compose.yaml

version: "3"
services:
  redis:
    image: redis
    container_name: app1_redis
    restart: always
  nginx:
    image: nginx:stable-alpine
    container_name: app1_nginx
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./media:/code/media:ro
      - ./static_collected:/code/static_collected:ro
    depends_on:
      - web
    networks:
      - default
      - swag
  web:
    build: .
    container_name: app1_web
    restart: always
    environment:
      - ENVIRONMENT=docker
      - URL_PREFIX=
      - DEBUG=false
    command: uwsgi uwsgi.ini
    volumes:
      - .:/code
    depends_on:
      - redis
    networks:
      - default

networks:
  swag:
    external:
      name: swag

就是在DjangoStarter原有docker-compose設定的基礎上增加了nginx的設定,使用官方的nginx映象: https://hub.docker.com/_/nginx

nginx.conf

上面的uwsgi.ini沒貼出來,也沒啥好說的,裡面開放的埠是8000,所以nginx設定裡面 upstream 寫的埠要對應 8000。

upstream django {
    ip_hash;
    server web:8000; # Docker-compose web伺服器埠 (也就是uwsgi的埠)
}

server {
    listen 8001; # 監聽8001埠
    server_name localhost; # 可以是nginx容器所在ip地址或127.0.0.1,不能寫宿主機外網ip地址

    charset utf-8;
    client_max_body_size 100M; # 限制使用者上傳檔案大小

    location /static {
        alias /code/static_collected; # 靜態資源路徑
    }

    location /media {
        alias /code/media; # 媒體資源,使用者上傳檔案路徑
    }

    location / {
        include /etc/nginx/uwsgi_params;
        uwsgi_pass django;
        uwsgi_read_timeout 600;
        uwsgi_connect_timeout 600;
        uwsgi_send_timeout 600;

        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_set_header X-Real-IP  $remote_addr;
    }
}

access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;

server_tokens off;

可以看到這個django應用內嵌的nginx設定開啟的8001埠

再回看上面的swag反向代理設定

set $upstream_app app1_nginx;
set $upstream_port 8001;
set $upstream_proto http;

就對應上了

這樣設定之後,docker compose up 啟動swag,再存取 app1.dealiaxy.com 就可以了~

全站HTTPS太舒服了,瀏覽器再也不太提示不安全了~

許可權問題

可以留意到swag的docker-compose設定裡面有倆環境變數,PUIDPGID,swag內部都設定好了,指定了這倆,容器啟動的時候就會以指定的使用者和使用者組執行,而不是預設以root執行,這樣會安全一些,而且掛載了 volume 出來的檔案,也不是root許可權,當前登入使用者不用 sudo 就能修改。

在 django 的docker里加入nginx的時候我有嘗試改成不用root執行,根據官方指引使用了 nginxinc/nginx-unprivileged 這個映象,也測試了在docker-compose設定裡傳入 user 引數,好像都沒什麼效果。

折騰了半天只好暫時放棄,後續有進展再繼續更新。

小結

這次專案說實在的沒啥技術含量,CRUD罷了,收穫的話就一點點:

  • 又熟悉了一些react的寫法
  • 把swag配好了,以後其他伺服器可以依樣畫葫蘆,極大提高生產力

參考資料