flask踩坑记

flask踩坑记

flask用来搭建项目框架,可以进行发开不仅仅是网页,这里完全可以进行基于内网的平台开发,凡是具有交互性质的都可以用flask进行开发

flask框架坑

flask与django在使用python开发的时候最最最重要的区别就在于,我擦,视图文件,模型文件,入口文件,form表单文件,配置文件,初始化文件,都是自己搭建自己写…

已经呕血一斗,第一次撸flask一脸懵逼,根本不知道为啥要这么设计这么多文件,导来导去的,可以一个py文件撸到黑啊…

一看就是没有项目经验的,你一个py文件撸通关,你是要把项目一个人搞完么?

文件划分,每个文件是干啥的,相互独立什么功能?前面已经有一篇文章专门介绍过了,这里就不再说了

flask文件松散,需要进行自己管理,django一建立就已经分配好了,巴适得板,但是里面有些用不到的也一并加载完成,所以就是这个项目没有用,实际上也一并加载完成,这就是重的原因

flask配置文件坑

写配置文件的目的就是希望为后续的维护提供方便的接口,面向过程的思维总是把很多东西写死,一旦写死后面再改的时候就找哭你,使用面向对象的思想,别写死了,一些重要的东西使用配置文件的方式进行写入

# 配置文件 使用类的方法进行配置
import datetime

def get_redis_object():
    # 导入对象
    from redis import Redis
    return Redis(host='127.0.0.1', port=6379) # 这里指定网址和端口


class BaseConfig():  # 定义一个基类用于其他配置继承
    DEBUG = True
    # 进行数据库的配置
    SQLALCHEMY_DATABASE_URI = 'mysql+cymysql://root:[email protected]:3306/flask_project'  # 路径
    SQLALCHEMY_TRACK_MODIFICATIONS = False  # 不进行数据的跟踪修改日志记录
    # 进行Session的配置
    SESSION_TYPE = 'redis'
    SESSION_REDIS = get_redis_object()
    PERMANENT_SESSION_LIFETIME = datetime.timedelta(hours=2)


# 定义开发人员使用的配置文件
class CMSDevConfig(BaseConfig):
    pass


class APIDevConfig(BaseConfig):
    pass

class CliDevConfig(BaseConfig):
    pass

导包的时候也需要看,不是所有的包都全部放在上面先导入,有些功能实际上只需要一瞬间执行,但是每次都导入就对代码造成运行压力,很烦,在需要的时候才用导包,因此这里有些导包会出现在函数内部

关于配置数据库,写法固定,关系型数据库使用的是mysql,非关系型数据库采用的是redis,需要注意的是redis如果不进行配置过期时间的话,其过期时间默认的是3600秒

整个配置文件使用的是继承的方式进行,对于不同的接口其配置信息也是不同的,当然存在一些共有的配置信息,因此这里为了尽量少写重复的代码,使用的是继承方式

配置文件作为单独的文件进行放置,那么当应用进行启动的时候需要将配置文件加载到app中

# 在这里进行app的创立
from flask import Flask
from flask_session import Session

# 模型注册函数
def register_model(app: Flask):
    from apps.project_model import db
    db.init_app(app)


# 蓝图注册函数
def register_bp(app: Flask):
    from apps.cms_app import cms_bp
    app.register_blueprint(cms_bp)

# 注册login插件创建对象
def register_login(app: Flask):
    # 导入对象
    from apps.libs.login_helper import login_manager
    login_manager.init_app(app=app)
    login_manager.login_view = 'cms.login'


# 创建初始化函数
def create_cms_app(app_str: str):
    # 创建app
    app = Flask(__name__)
    # 进行配置文件注册
    app.config.from_object(app_str)
    app.secret_key = '123456'
    # 进行redis的注册
    Session(app=app)
    # 进行注册模型
    register_model(app)
    # 进行login注册
    register_login(app)
    # 进行蓝图注册
    register_bp(app)
    # 将app进行返回
    return app


# 注册蓝图函数
def register_cli_bp(app: Flask):
    from apps.demo_apis import api_bp
    app.register_blueprint(api_bp)


def create_cli_app(app_str: str):
    app = Flask(__name__,static_folder='./web_client',static_url_path='')
    # 获取配置文件
    app.config.from_object(app_str)
    app.secret_key = '123456'
    # 进行redis的注册
    Session(app=app)
    # 进行模型注册
    register_model(app)
    # 注册蓝图
    register_cli_bp(app)
    # 返回app
    return app

上面是初始化的文件,存在两个创建的app,一个是用户使用的app,一个是cms后台使用的app,注意到所有的注册行为都分离为函数进行操作

在app注册的时候存在顺序性,比如模型还没有建立就注册了蓝图,那么蓝图里面很多是对数据的增删改查,这种方式就会导致查询数据库的时候根本还没有表,直接报错,所以一定会存在顺序

配置函数调用之后都有一个app.secret_key的设置,这个东西实际上就是使用cookie的是时候需要配置的,这是为后面的token颁发配置的,里面的字符串可以随便写,这里只是类似加盐的方式进行自定义秘钥加入.

还需要注意到进行Flask()实例化的时候的静态文件名字配置,当然在开发阶段配置这个没有任何意义,毕竟上线之后会重新配置,这里只是为了开发的方便进行配置的默认的就是static文件名作为静态文件,如果要单独配置就像里面的static_folder=’./web_client’ static_utl_path=’’ 意思是以web_client文件夹作为静态文件的存储路径

flask模型坑

orm模型中的经典解释就是django和flask的模型,这个模型的类名就是数据库中表名,类属性就是表里面的字段名,每一个类的实例化对象就是一条数据,模型看似简单实际上内置的很多坑,莫名其妙报错

同样是为了继承方便,先写一个基础模型类

# 在这里创建模型对象
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

# 创建模型对象
db = SQLAlchemy()

# 定义一个基类用于其他的模型类继承
class BaseModel(db.Model):
    __abstract__ = True # 防止数据库迁移的时候生成BaseModel表
    id = db.Column(db.Integer, primary_key=True)
    is_delete = db.Column(db.Integer, default=0) # 0表示不删除,1表示删除
    create_time = db.Column(db.DATETIME, default=datetime.now) # 创建时间
    change_time = db.Column(db.DATETIME, default=datetime.now, onupdate=datetime.now) # 修改时间


    # 定义一个写库之前对form表单中的数据转换函数,其作用在于每一个form表单拿到数据之后就会根据属性去找model中的对应属性进行一一对应写入,非常麻烦,这里使用循环进行写入
    def form_to_model(self, attr):
        # 从attr中拿出数据进行存储
        for k,v in attr.data.items():
            # 如果数据库中存在这个表单中的字段才添加
            if hasattr(self, k) and k != 'id': # id为自增不允许外部传入
                setattr(self, f'{k}', v)

    # 定义字典化方法
    def __getitem__(self, item):
        return getattr(self, item, None) # 存在就拿出属性的值,不存在就直接返回None


# 导入执行的模型py文件
from apps.project_model import cms_modle
from apps.project_model import client_modle

模型的公共文件,说白了就是pycharm中创建一个包之别人自动的给你创建一个init文件,这个文件非常有意思,每次这个包被导入的时候这个文件会先自动执行,所以这里放着的都是很多基础配置等等,注意到前面的配置文件存在一个模型注册,模型注册需要导入的db就是在这个文件中,而且一旦db被导入,很好,这个文件全部被执行了

里面的两个便利的方法一个是用于后面form表单的时候,一个是用于后面处理数据的时候使用,这个后面会介绍,先来看看基础模型中的abstract,如果不加,那么你只要执行迁移和更新,你就会发现你的数据库里面会存在一个basemodel表,这个坑已经踩过一次了

基础模型之后所有的模型都可以继承整个继承模型,这就非常方便了,接下来看一个模型

class OrderInfo(BaseModel):
    __tablename__ = 'order_info'
    order_code = db.Column(db.String(32), unique=True)
    shop_pub_id = db.Column(db.String(32), db.ForeignKey('shop.pub_id'))
    user_id = db.Column(db.Integer, db.ForeignKey('client_user.id'))
    # 订单送货地址
    order_address = db.Column(db.String(128))
    # 订单价钱
    order_price = db.Column(db.Float, default=0)
    # 订单状态
    order_status = db.Column(db.Integer, default=0)
    # 第三方交易号
    trade_sn = db.Column(db.String(128), default='')

    user = db.relationship('ClientUser', backref='link_orders')
    shop = db.relationship('Shop', backref='link_orders')

    def keys(self):
        return 'order_address', 'order_price', 'order_code'

    def get_status(self):
        if self.order_status == 0:
            return "待付款"
        else:
            return "已付款"

里面的tablename定义的你在数据库建立的表名,如果不写,默认你的类名是使用大驼峰命名的,数据库的表名会将大驼峰命名自动转变成小写并用_进行分隔

这个是订单表,ForeignKey明显是外键,设置外键的方法和Django中的方法稍微有点区别,外键里面的字符串表示外键连接的表名,后面的.id表示已连接表中的哪个字段作为外键连接

外键连接字符串大小写很有讲究,少年,这个表示去哪个表里面连接那个字段,你见过哪个数据库的表名有大写的,这里只能识别小写,没有为什么,规定就是这样,不信你就试试大写看看报不报错

看起来没啥文件,但是外键连接的表如果不是连接的表id,那么还需要其他设置,不然必定报错,比如连接shop.pub_id,这个pub_id并不是shop表的id,这个pub_id是单独的字段

看看shop表中的pub_id需要设置什么

class Shop(BaseModel):
    __tablename__ = 'shop'
    pub_id = db.Column(db.String(16),index=True, unique=True)  # 对外显示的唯一id
    .........

一个字段能够作为外键的三个条件: 1 必须唯一 2 必须设置索引 3 字段属性设置相同

unique表示唯一,index设置为索引,至于属性设置就不说了,不设置你连Shop表都会GG

为何外键连接的id属性不需要设置这些?别人表的id本身就是唯一并且是索引,少侠,还需要设置么?你这个外键连接的是自己定义的字段,当然需要设置,本身的Shop表你不写index和unique是不会报错的,但是只要外键连接这个字段,不设置必报错

这里再把Shop模型后面的自定函数写一下

    def __repr__(self):
        return f'<Shop> {self.shop_name}'  # 改写对象打印

    # 定义一个函数生成一个唯一的uuid
    def generate_uuid(self):
        self.pub_id = ''.join(str(uuid.uuid4()).split('-')[:3])

    # 定义这个模型字典化的keys方式
    def keys(self):
        return ['shop_name', 'brand', 'on_time', 'is_express', 'is_insurance', 'is_invoice', 'is_identify', 'send_star_price', 'send_spend', 'information', 'special_offers']

repr与str方法有点像,但是不同,定义的str魔法方法是改写字符串化的打印内容,而repr这是对象打印的时候,定义了一个自动生成pub_id的方法

pub_id是对外显示的id号,在我爬取别人数据的时候经常在浏览器中修改id号看看变化,而且通过id号能够推断出别人大致有多少用户,没办法,前端后端的数据传输不可避免的要传递id,那么pub_id也搞一个唯一性的编码,并且是乱序的,让别人没法通过传递的数据猜测出有用的信息,这就很不错了

后面的keys()方法是字典化的方法,后面介绍

flask_form表单坑

form表单都是用来前端传入过来的数据是否合法的问题,虽然前端能够写js进行验证,但是那是对正常用户而言的,如果是故意构造传输数据,绕过前端,那么 也是有可能的,所以还是需要记性验证

# 注册表单验证
class UserForm(BaseUserForm):
    # 只有注册的时候才验证用户名重复
    password_again = PasswordField(
        label='重输密码',
        validators=[
            validators.DataRequired(message='密码不能为空'),
            validators.equal_to('password', message='两次密码不一致')
        ],
        render_kw={'class': 'form-control'}
    )

    def validate_name(self, form_data):  # 自定义异常
        name = form_data.data
        user = User.query.filter_by(name=name).count()  # 如果没有则user=0
        if user == 1:
            raise ValidationError("用户已存在")

不用想,我肯定是继承了别的类少写一大片代码,这里需要注意的是自定义异常函数,首先函数必须以validate_开头,后面的name必须是字段名,这里好像没有看到name字段,那是因为我的name字段放在了BaseUserForm里面了,这个类里面没有,不代表表里面没有.要验证的数据进行传入,form_data本身是个对象,要.data才能拿到传过来的数据

自定义异常函数别人框架给你封装好了,会在表单验证对应字段的时候自动调用,于是关于返回值必须使用抛异常的方式进行抛出,而不是return,别人框架自动捕获这个异常,把异常信息进行返回,不用我们操心

与Django不同的是这里可以不返回任何东西,在验证的时候,只要没有发现异常就表示默认的通过了

好的那么视图函数中又是怎么调用的

@cms_bp.route('/register/', endpoint='register', methods=['GET', 'POST'])
def register():
    # 都需要form表单内容
    userform = UserForm(request.form)
    if request.method == 'POST' and userform.validate():  # 使用的post方式并且表单验证通过
        # 使用post验证通过则进行写入数据库
        user = User()
        user.form_to_model(userform)
        # 对密码进行加密
        user.set_pwd_hash(userform.password.data)  # 可以直接调用对象的方法
        # 最后进行提交
        db.session.add(user)
        db.session.commit()  # 提交数据库
        # 注册成功后需要跳转到登录界面
        return redirect('/login/')
    return render_template('cms_templates/reg.html', userform=userform)

获取form表单的数据,使用request.form的方法进行获取,直接传入UserForm类中,得到的就是转变之后的数据对象,调用validate方法的时候才会进行数据判断,其返回的是布尔值

以上就是完整的判断通过后写库的过程,form表单就这么使用

但是注意到form类传进去request对象后出来的是数据对象,打个断电看这个对象的时候你会发现里面所有的信息都被封装成各种属性,这就很方便了,注意到下面如果验证没有通过的话直接对页面返回了userform对象,而且这个对象非常有趣的是对象.类属性名,返回的是类似一个html标签的字符串,返回给html页面是不是很方便,根本不用写什么表单,别人直接就是 ,巴适得板,这个在页面回显中用的很多

回显后面说,还是先说写入数据的问题,朋友,表单中如果有10多个内容,传过来的数据你拿到后就需要使用user对象.类属性名=userform对象.类属性名.data进行逐个赋值,有10多个就写10多行,累不累?

所以需要想办法进行减少这种无效的开发手段

我们再来看看改写的快速方法

    def form_to_model(self, attr):
        # 从attr中拿出数据进行存储
        for k,v in attr.data.items():
            # 如果数据库中存在这个表单中的字段才添加
            if hasattr(self, k) and k != 'id': # id为自增不允许外部传入
                setattr(self, f'{k}', v)

上面就是调用了这个方法进行转变,首先这是一个对象方法,写在基本模型类里面的方法,那么任何一个子类都可以调用,当对象调用的时候self就是调用的对象,传入的是需form对象,form对象.data就是一个存储着表单里面数据的字典,取出这个字典的键值对,利用setattr给模型对象添加属性和对应的值,这就很完美的完成了前面冗余繁琐的挨个赋值操作.

但是这里需要注意的是,命名的规范,如果从前端页面传过来input的name值与模型的name值不同,那么这个办法就GG

所以要给前端对接好,不然这个方法也没有什么卵用

下面是回显数据给页面,回显逻辑是从数据库里面拿出数据,然后返回给前端,现在问题是如果你返回的只是json,前端还是需要自己去写html,麻烦啊

form类接收数据后能够转变成带有样式的html标签字符串返回给前端,我擦,这就巴适得板,只需要把数据搞出来给form类转变一下返回给前端就行了

form类除了能接收request.form这种数据类型外还有一个关键字参数data={}的传入方式,意思就是说只要我们能构造一个字典传进去,那就能够构造一个form对象返回给前端,爽的一匹

那么查询出来的模型对象身上的属性值就是我们想要的数据,属性名就是我们想要的前端name名,这里就需要将这种对象转变为字典数据传进去

转变字典dict方法,但是这个对象传进去直接报错,因为不是所有的对象都能转变成为字典,dict方法内部实际上使用的是一个魔法方法和以keys方法实现的,有兴趣可以看源码

那么我们能不能针对某个类进行改写这个魔法方法和自己构建一个keys方法呢,如果可以那么调用dict的时候就能实现转变为字典

    # 定义字典化方法
    def __getitem__(self, item):
        return getattr(self, item, None) # 存在就拿出属性的值,不存在就直接返回None

这就是魔法方法,item是keys的返回值,是一个列表的元素,这个魔法方法会自动的去遍历这个列表每次遍历都执行一次,那么我们把这个列表搞到就行了,而item就是列表内的元素,现在只需要把我需要的属性名字写进列表就可以了

下面就在具体的模型类里面构建keys方法,因为魔法方法是在基类里面,所以不用担心不会被调用

    # 定义这个模型字典化的keys方式
    def keys(self):
        return ['shop_name', 'brand', 'on_time', 'is_express', 'is_insurance', 'is_invoice', 'is_identify', 'send_star_price', 'send_spend', 'information', 'special_offers']

针对具体的模型有自己需要回显的内容,这个模型就需要回显很多,所以咯,手动写也没问题,只要你不觉得繁琐,那么这个的回显视图如下

# 回显店铺详情页面
@cms_bp.route('/shop_detail/<shop_id>', endpoint='shop_detail', methods=['GET', 'POST'])
@login_required
def shop_detail(shop_id):
    # 获取用户的id
    user_id = current_user.id
    # 从数据库中查询出指定的店铺详情
    try:
        shop_information = Shop.query.filter_by(user_id=user_id, pub_id=shop_id).first()
    except:
        # 如果出错表示进行提示输入非法
        return '输入非法'
    # 获取shop_information上的所有属性
    shop_form = ShopForm(request.form)
    if request.method == 'GET':
        shop_form = ShopForm(data=dict(shop_information))
    elif request.method == 'POST' and shop_form.validate(): # 使用的是post并且验证通过
        shop_information.form_to_model(shop_form)
        db.session.commit()
        return redirect(url_for('cms.show_shop')) # 跳转回到个人店铺显示
    page_name = '更新'
    return render_template('cms_templates/add_shop.html', shop_form=shop_form,page_name=page_name)

回显操作够骚,巴适得板,一句话shop_form = ShopForm(data=dict(shop_information))搞定

flask视图函数坑

注意到上面的视图函数有个验证的login_required装饰器,这个是验证是否登录的装饰器,装饰器行为就是在你想进入这个视图函数之前进行装饰判断

回头去看看前面说配置文件的时候里面有一段app实例化的代码,里面说过注册模型和视图都存在顺序关系,注意到里面有一小段

def register_login(app: Flask):
    # 导入对象
    from apps.libs.login_helper import login_manager
    login_manager.init_app(app=app)
    login_manager.login_view = 'cms.login'

这个就是用来注册装饰器的,apps.libs.login_helper包是我自己定义的,来看里面都执行了什么

from flask_login import LoginManager
from apps.project_model.cms_modle import User

# 实例化对象
login_manager = LoginManager()


# 该函数作为中间件,只要传入对应的id就能根据id进行拿出对应的id的整个数据信息
# 插件内置的就已经将这个对象保存到了current_user中,这个变量可以直接在html中写
@login_manager.user_loader
def load_user(user_id: str):
    return User.query.get(int(user_id))

apps.project_model.cms_modle包又是我自己定义的,里面导入的是用户模型,意思就是针对这个模型进行绑定,这个是为了方便些前端html页面能够拿到对应用户的信息,至于整个是怎么判断的,怎么实现的需要看源码,少侠,源码我看的头都大了,本来还想挑战一下,结果发现自己功力尚浅,撸不平它,现在知道它这么做就对了,先用着,后面功力达到了之后再撸平立面的源码,反正经过上面的注册就没问了

除了验证登录之外,还有一种办法,就是使用token进行验证,这里不像django里面,别人都已经封装好了的,当时flask里面还是要自己来写

from itsdangerous import TimedJSONWebSignatureSerializer as Serializer, SignatureExpired, BadSignature
# 登陆api
@api_bp.route('/login/', endpoint='login', methods=['POST'])
def login():
    # 获取登录的信息
    username = request.form.get('name')
    password = request.form.get('password')
    # 进行数据库查询
    user = ClientUser.query.filter_by(username=username, password=password).first()
    if user:
        # 存在则表明查到了
        # 构建token令牌
        ser = Serializer('client_user', expires_in=3600)  # 生成令牌对象 client_user是秘钥
        orders = ser.dumps({"user_id": user.id})  # 把user的id加入到令牌中进行混合加密

        data = {
            "status": "true",
            "message": "登录成功",
            "user_id": user.id,
            "username": user.username,
        }
        response = make_response(jsonify(data))
        response.set_cookie("client_token", orders)
        return response
    else:
        return jsonify({"status": "false", "message": "反正有错"})

这种方式就是写接口的方式,前端传过来的就是form表单,那么我们直接获取内容,这都不是重点,重点是颁发token令牌,这很关键,导入令牌后就可以自己设定过期时间,这里调用了dumps方法就像把令牌压缩一下生成一个唯一的别人无法破解的字符

同时构建响应对象,把加密后的东西强行设置到cookie中,这里紧紧是这么做还是会报错的,因为必须对cookie配置秘钥,注意到前面说配置文件的时候里面有一个

    app.secret_key = '123456'

这个就是自己设置的,必须设置,写什么随你,不写报错

一切撸完了之后实现了登录后颁发token令牌,然后呢?需要验证令牌,后续的每一个接口都需要验证token,所以需要写一个装饰器进行验证

# 进行验证client_token的装饰器
def judge_client_token(function):
    @wraps(function)
    def inner_function(*args, **kwargs):
        token = request.cookies.get("client_token")  # 从cookie中获取值
        try:
            data = Serializer('client_user').loads(token)  # 进行验证
        except SignatureExpired:
            return jsonify({"status": 401, "message": "token已经过期了"})
        except BadSignature:
            return jsonify({"status": 402, "message": "token不合法"})
        # 没有发生异常表示通过了
        g.client_user_id = data.get('user_id')
        return function(*args, **kwargs)
    return inner_function

装饰器中两个坑

第一个坑是@wraps(function)这个必须加,不加只有一种情况不报错,当你这个装饰在其他的py文件中导入并且只使用一次的时候不会报错,如果使用多次必报错,这个是一种类似申明的意思

第二个坑是验证完成之后从token中取出你自己压缩进去的user_id,本来的思路是你把它传给视图函数,但是如果你写一个参数的传的视图函数直接GG,因为视图函数接收到的参数是浏览器里面传的指定参数

那么解决方案有几种,最简单的就是导入g对象,这个对象和request对象一样,自带上下文,给这个对象里面强加一个属性保存这个user_id,这样视图函数就能通过g对象取出id进行数据的查询了

在实际项目中前端经常发送的是json格式数据给后端,后端查询数据库后以json格式进行返回,那么前端不同的请求方式就存在不同的意义,对某个地址进行多种方式请求,那么就存在多种返回行为,比如对某个地址,里面要实现显示完整列表数据,显示单个数据,添加数据,修改数据,一个地址实现四个功能

简单做法是定义四个api接口,我闲的蛋疼把四个写到一个接口里面,代码过几周后我自己都看不懂了

# 收获地址api
@api_bp.route('/address/', endpoint='address', methods=['GET', 'POST'])
@judge_client_token
def get_address_list():
    address_id = request.args.get('id', None)
    user_id = g.client_user_id  # 获取已经通过验证的用户id
    if request.method == 'POST':  # 使用POST方式进行提交
        address_form = AddressForm(request.form)  # 获取表单中的值
        index = request.form.get('id', None)
        if index:  # 如果存在则表示是修改具体的某个地址
            if address_form.validate(): # 验证通过
                address = Address.query.filter_by(link_user_id=user_id).all()[int(index)-1] # 根据id拿出对象
                address.form_to_model(address_form) # 把表单中的值重新赋值
                db.session.commit()
                return jsonify({"status": "true",'message': 'update success'})
        else:  # 什么也不带就是创建某个地址
            if address_form.validate():  # 如果验证通过
                address = Address()
                address.form_to_model(address_form)
                address.link_user_id = int(user_id)
                db.session.add(address)
                db.session.commit()
                return jsonify({"status": "true", 'message': 'success'})
        return jsonify({"status": 'false',
                        "message": f"{[i.errors[0] for i in address_form if i.errors != []][0]}"})  # 如果提交的有问题就进行挨个输出错误信息
    else:  # 表示采用的是GET方式请求
        if address_id:  # 如果存在则表示需要修改,GET表示请求回显
            address = Address.query.filter_by(id=int(address_id), link_user_id=user_id).first()
            return jsonify(dict(address))
        else:  # 不带ID表示全查
            address = Address.query.filter_by(link_user_id=user_id).all()
            data = [{**dict(address_obj), "id": num + 1} for num, address_obj in enumerate(address)]
            return jsonify(data)

有兴趣的可以看看逻辑

如果你对视图,form,和数据模型比较熟悉的话应该能看懂

猜你喜欢

转载自blog.csdn.net/weixin_43959953/article/details/86292942