前言#
最近有个项目到一段落,做个小结记录。
内容可能会多次补充,在博客上实时更新哈~
如果是在公众号阅读这篇文章,可以点击「查看原文」访问最新版本~
这个项目是前后端分离,后端为了快,依然用我的DjangoStarter框架。前端一开始是小程序,后面突然换成公众号H5的形式,还好我用了Taro,大差不差。
不过Taro目前没啥好用成熟的组件库,前一个项目本来用着Taroify,不过用了一半项目还没做完,Taroify的作者就跑路不维护了~ 虽然但是,还是能用,把旧项目的一些代码复用一下,也不是不行。
总体的开发体验就是很一般,虽说React写前端舒服多了,但组件库实在是拉胯… 如果下个项目依然要用Taro的话,估计得试试新出的NutUI-React了。
说回正题,这次我从Web开发、部署这几方面对这个项目做个小总结。
后端#
后端用DjangoStarter模板,自从我上次升级了v2版本之后,还没实战过,这次项目上使用了,还是稳得一批,(所以点star的同学可以放心用哈~)
之前oauth部分只有企业微信,微信登录还是todo,这次因为接入公众号,我顺手也把微信登录做了,其实跟企业微信基本没啥区别。
"双标"的ModelViewSet#
drf的ModelViewSet,可以快速生成crud接口,不过默认权限控制很粗糙,只能选择三种:
- 已登录可访问
- 管理员可访问
- 任何人可访问
但正常的场景是,假设有个文章接口,用户只能管理自己写的文章,管理员可以访问全部文章。也就是要对不同角色的用户区别对待~
要实现的话可以这样,重写 ModelViewSet
的 get_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
}
父组件使用的时候
日历组件向父组件传值,也就是触发事件
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,遇到挺多坑的,也查了很多资料,最终完美搞定~
很多时候虽然文档很齐备,但因为各种条件不一致,很难一下子搞起来。
- 官方文档: https://docs.linuxserver.io/general/swag
- 国内有人搞了个中文文档,也很不错: https://linuxserver.watercalmx.com/general/swag.html
首先在域名控制台添加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.com
和 ns2.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配置里面有俩环境变量,PUID
和 PGID
,swag内部都配置好了,指定了这俩,容器启动的时候就会以指定的用户和用户组运行,而不是默认以root运行,这样会安全一些,而且挂载了 volume 出来的文件,也不是root权限,当前登录用户不用 sudo 就能修改。
在 django 的docker里加入nginx的时候我有尝试改成不用root运行,根据官方指引使用了 nginxinc/nginx-unprivileged
这个镜像,也测试了在docker-compose配置里传入 user
参数,好像都没什么效果。
折腾了半天只好暂时放弃,后续有进展再继续更新。
小结#
这次项目说实在的没啥技术含量,CRUD罢了,收获的话就一点点:
- 又熟悉了一些react的写法
- 把swag配好了,以后其他服务器可以依样画葫芦,极大提高生产力
参考资料#
- LinuxServer.io | 中文文档 - https://linuxserver.watercalmx.com/
- Docker Compose 网络设置 - https://juejin.cn/post/6844903976534540296
- 大江狗的Docker完美部署Django Uwsgi+Nginx+MySQL+Redis - https://zhuanlan.zhihu.com/p/145364353