项目完成小结 - Django-React-Docker-Swag部署配置

时间:2022-12-07 07:08:44

前言

最近有个项目到一段落,做个小结记录。

内容可能会多次补充,在博客上实时更新哈~

如果是在公众号阅读这篇文章,可以点击「查看原文」访问最新版本~

这个项目是前后端分离,后端为了快,依然用我的DjangoStarter框架。前端一开始是小程序,后面突然换成公众号H5的形式,还好我用了Taro,大差不差。

不过Taro目前没啥好用成熟的组件库,前一个项目本来用着Taroify,不过用了一半项目还没做完,Taroify的作者就跑路不维护了~ 虽然但是,还是能用,把旧项目的一些代码复用一下,也不是不行。

总体的开发体验就是很一般,虽说React写前端舒服多了,但组件库实在是拉胯… 如果下个项目依然要用Taro的话,估计得试试新出的NutUI-React了。

说回正题,这次我从Web开发、部署这几方面对这个项目做个小总结。

后端

后端用DjangoStarter模板,自从我上次升级了v2版本之后,还没实战过,这次项目上使用了,还是稳得一批,(所以点star的同学可以放心用哈~)

之前oauth部分只有企业微信,微信登录还是todo,这次因为接入公众号,我顺手也把微信登录做了,其实跟企业微信基本没啥区别。

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配好了,以后其他服务器可以依样画葫芦,极大提高生产力

参考资料