Learn Python 3 :Flask Web开发小记

最近看了Flask Web开发:基于Python的Web应用开发实战,书中详细介绍了Web程序的开发、测试、部署过程,值得一读!我在书中例子的基础上做了些更改,实现了一个简单的个人博客:NiceBlog,仅作为个人学习,还有许多不足的地方待完善,这里做一些简单的记录,方便以后查阅,代码放在了Github上:https://github.com/SheHuan/NiceBlog

一、功能

1、对于普通用户,主要有如下功能:

  • 注册、登录、重置密码(邮箱验证)
  • 文章列表、详情
  • 评论
  • 喜欢

2、对于管理员,除了有普通用户的功能,主要有如下功能:

  • 写文章(Markdown编辑)
  • 用户权限管理(管理喜欢、评论的权限)
  • 评论管理(删除、屏蔽)

3、为移动端提供相关api接口

二、项目结构

遵循了书中多文件Flask程序的基本结构,下边是NiceBlog的项目结构:
|-NiceBlog
   |-app/ 主目录
      |-api/ 为移动端提供接口的蓝本
      |-auth/ 权限认证的蓝本
      |-main/ 主体功能的蓝本
      |-manage/ 管理相关功能的蓝本
      |-static/ 静态资源目录(icon、js、css)
      |-templates/ html模板目录
      |-__init__.py 初始化项目的工厂函数
      |-decorators.py 自定义的装饰器
      |-email.py 发送邮件功能
      |-excepitions.py 自定义异常处理
      |-models.py 数据模型
   |-migrations/ 数据库迁移脚本目录
   |-nb_env/ 虚拟环境
   |-tests/ 单元测试目录
   |-config.py 配置文件
   |-manage.py 启动程序以及其他的程序任务
   |-requirements.txt 项目的依赖包列表

三、实现

1、工厂函数

一个简单的Flask Web程序可以写在单文件中,test.py

app = Flask(__name__)

# 定义的路由
@app.route('/')
def index():
    return '<h1>Hello World!</h1>'

if __name__ == '__main__':
    app.run()

但是执行程序时,由于在全局作用域创建导致无法动态修改配置,也导致了单元测试时无法在不同配置环境运行程序。所以可以把程序的创建转移到可显示调用的工厂函数中,也就是前边项目结构中的__init__.py,在工厂函数中导入需要的Flask扩展:

def create_app(config_name):
    app = Flask(__name__)
    # 导致指定的配置对象
    app.config.from_object(config[config_name])
    # 调用config.py的init_app()
    config[config_name].init_app(app)

    # 初始化扩展
    bootstrap.init_app(app)
    mail.init_app(app)
    moment.init_app(app)
    db.init_app(app)
    login_manager.init_app(app)
    pagedown.init_app(app)
    return app

2、蓝本

新的问题来了,使用工厂函数后,程序在运行时创建,而不是在全局作用域,必须等到执行create_app()后才能使用@app.route()装饰器,这时就要使用蓝本了,在蓝本中也可以定义路由,但是定义的路由处于休眠状态直到蓝本注册到程序后在成为程序一部分,例如main蓝本的目录结构如下:
|-NiceBlog
   |-app/ 主目录
      |-main/ 主体功能的蓝本
         |-__init__.py 创建蓝本
         |-errors.py 蓝本的错误处理
         |-forms.py 蓝本的表单
         |-views.py 蓝本的路由

首先看一下__init__.py

# 两个参数分别指定蓝本的名字、蓝本所在的包或模块(使用 __name__即可)
main = Blueprint('main', __name__)
# 导入路由模块、错误处理模块,将其和蓝本关联起来
# 在蓝本的末尾导入在两个模块里还要导入蓝本,防止循环导入依赖
from app.main import views, errors
2.1、表单

forms.py是当前蓝本中的表单,项目中使用了FlaskForm,可以方便的完成表单校验,例如创建、编辑文章的表单:

class BlogForm(FlaskForm):
    title = StringField('请输入文章标题', validators=[DataRequired(), Length(1, 128)])
    labels = StringField('文章标签(标签之间用空格隔开)', validators=[DataRequired()])
    summary = TextAreaField('文章概要', validators=[DataRequired()])
    content = TextAreaField('文章内容', validators=[DataRequired()])
    preview = TextAreaField('文章预览', validators=[DataRequired()])
    publish = SubmitField('发布')
    save = SubmitField('保存')
2.2、路由

views.py就是在蓝本中定义的路由,例如主页的路由:

@main.route('/create-blog', methods=['GET', 'POST'])
@admin_required
def create_blog():
    """
    写新文章
    """
    form = BlogForm()
    if form.validate_on_submit():
        blog = None
        if form.publish.data:
            # 发布
        elif form.save.data:
            # 保存草稿
        return redirect(url_for('main.index'))
    return render_template('markdown_editor.html', form=form, type='create')

注意装饰器为当前蓝本的名字main,而不是之前的appcreate_blog()称为视图函数,一个路由保存了URL到视图函数的映射关系。redirect(url_for('main.index'))代表重定向到主页,url_for()的参数为要跳转到的URL对应的视图函数名,但需要加上视图函数所在的蓝本名,即main.indexrender_template()是Flask提供的函数,把Jinja2模板引擎集成到了程序中,第一个参数是模板名称对应一个html文件,即执行该视图函数后最终要渲染的页面,后边的参数为传递给模板的参数。

2.3、错误处理

errors.py是蓝本中的错误处理程序,例如:

@main.app_errorhandler(404)
def page_not_found(e):
    if request.url.find('api') != -1:
        return jsonify({'error': '请求的资源不存在', 'code': '404', 'data': ''})
    return render_template('error/404.html'), 404

如果使用@main.errorhandler装饰器只有当前蓝本的错误才能触发,为了使其它错误也能触发所以使用了@main.app_errorhandler装饰器

2.4、注册蓝本

其它蓝本的定义也类似,最后需要在工厂函数中重注册蓝本,例如:

def create_app(config_name):
    # ......
    # 注册main蓝本
    from app.main import main as main_blueprint
    app.register_blueprint(main_blueprint)

    # 注册auth蓝本
    from app.auth import auth as auth_blueprint
    # 使用url_prefix注册后,蓝本中定义的所有路由都会加上指定前缀,/login --> /auth/login
    app.register_blueprint(auth_blueprint, url_prefix='/auth')

    return app

3、前端

3.1、Jinja2

Flask使用Jinja2作为模板引擎,模板是一个包含响应文本的HTML文件,其中包含只有在请求的上下文才知道的动态占位变量。默认情况下,模板保存在templates目录。

Jinja2模板中{{ 变量名 }}代表一个变量(注意变量名两边有一个空格,可以识别任意类型的变量),从渲染模板时使用的数据中获取。如果变量的值是HTML,由于转义的原因导致浏览器不能正常显示HTML代码,所以需要使用safe过滤器,例如文章详情的HTML显示就需要这样处理,过滤器写在变量名后用竖线隔开{{ 变量名|过滤器名 }}

Jinja2中用{% 控制语句 %}代表控制结构来改变模板的渲染流程,例如:

# 条件控制
{% if xxx %}
    <h1>Android</h1>
{% else %}
    <h1>iOS</h1>
{% endif %}
# for循环
{% for x in xs %}
    <li>{{ x }}</li>
{% endfor %}
# 导入
{% import 'xxx.html' %}
# 包含
{% include 'xxx.html' %}

导入、包含的目的都是为了复用,还可以通过继承实现复用,类似于类的继承:

# 继承
{% extends "base.html" %}

通过继承,模板中重复的代码都可以写在父模板里,例如导航条和页面底部footer就可以放在父模板里。

3.2、Bootstrap

前端使用了Bootstrap框架,它提供了良好的CSS规范,可以帮助我们更好的美化界面,具体的可参考:
https://v3.bootcss.com/,要在项目中集成它可以使用Flask的Flask-Bootstrap扩展,直接在PyCharm安装,并在工厂函数中初始化,还要让项目的父模板继承Bootstrap的基类模板:

# common_base.html
{% extends "bootstrap/base.html" %}

Bootstrap的基类模板base.html提供了一个网页框架,包含了Bootstrap中的所有CSS和JS文件。除此之外基类模板还定义了许多可在其子类模板中重定义的块,使用格式如下:

{% block 块名称 %}
{% endblock %}

常用的块如下:

块名称 含义
head <head>标签中的内容
title <title>标签中的内容
body <body>标签中的内容
styles css样式单的定义
navbar 自定义的导航条
content 自定义的页面内容
page_content 定义content在内部
scripts JS声明,一般在模板尾部

注意如在子模板在模板已有的块中添加新内容,需要使用super()函数:

{% block scripts %}
    {{ super() }}
    <!-- 新加的内容 -->
{% endblock %}
3.3、Flask-WTF

2.1中我们已经看到了用Flask-WTF定义表单的方式,即自定义的表单类继承FlaskForm类,并添加需要的类变量,Flask-WTF定义了许多标准字段可以被渲染成指定的表单类HTML标签,例如:

字段名 对应的H5标签
StringField 文本框
TextAreaField 多行文本框
PasswordField 密码输入框
BooleanField 复选框
SubmitField 表单提交按钮

同时Flask-WTF还提供了许多常用的表单校验函数,例如:Email()EqualTo()DataRequired()Length()等等,当点击提交按钮时,会自动校验表单是否满足预定义的条件。

2.2中,我们通过参数把表单类的实例同步form参数传入模板:

render_template('markdown_editor.html', form=form, type='create')

在模板中可以通过如下方式生表单(只保留了部分核心代码):

<form method="post" role="form" class="height-full">
        {{ form.hidden_tag() }}
        {{ form.title(id="title", class="form-control editor-blog-title", placeholder=form.title.label.text) }}
        {{ form.labels(class="form-control editor-blog-area", placeholder=form.labels.label.text) }}
        {{ form.summary(class="form-control editor-blog-area", placeholder=form.summary.label.text, rows=3) }}
        {{ form.publish(class="btn btn-info") }}
        {{ form.save(class="btn btn-success") }}
    </form>

这样的好处是我们能自定义表单的样式等等,但是工作量蛮大的,如果对表单样式没有特殊的需求,Bootstrap中的表单样式可以满足需求,可以通过Flask-Bootstrap提供的辅助函数快速的渲染表单,只需要如下两步:

{% import "bootstrap/wtf.html" as wtf %}
{{ wtf.quick_form(form) }}

例如登录的H5模板就是这样做的。
form.hidden_tag() 模板参数将被替换为一个隐藏字段,用来实现在配置中激活的 CSRF 保护。如果你已经激活了CSRF,这个字段需要出现在你所有的表单中。

2.2中,如果点击表单提交按钮,所有的表单都能成功通过校验,则form.validate_on_submit()的值为True,否则校验失败,网页会出现对应提示。如果有两个提交按钮,那么在校验成功后,还需要判断点击的是哪个按钮,否则所有的按钮都执行了同一个操作。例如我们的文章发布和保存按钮,当表单类中的按钮字段的data属性为True则代表该按钮被点击,例如:

if form.publish.data:
    # 发布
elif form.save.data:
    # 保存草稿
3.4、jQuery

有些页面需要在相关操作后修改控件的CSS样式,例如文章详情的喜欢取消喜欢按钮,最简单的方式是操作成功后直接刷新整个页面,但这样体验并不好,更好的方式是局部刷新。这里直接使用jQuery(Bootstrap也提供了类似的操作,同时包含了jQuery,不需要单独导入jQuery)来实现。使用jQuery强大的选择器功能可以方便的得到要操作的DOM节点,按钮的点击也是发起一个请求,jQuery也集成了ajax,可以方便的处理请求,在请求完成后根据响应结果来更改DOM节点的样式。看下按钮的点击事件:

favourite = function (id) {
        if ($('.blog-favourite-btn').length > 0) {//取消喜欢
            $.get('/manage/blog/cancel_favourite', {
                id: id
            }).done(function (data) {
                $('.blog-favourite-btn span').removeClass('glyphicon-heart').addClass('glyphicon-heart-empty');
                $('.blog-favourite-btn').removeClass('blog-favourite-btn').addClass('blog-unfavourite-btn');
            })
        } else if ($('.blog-unfavourite-btn').length > 0) {//喜欢
            $.get('/manage/blog/favourite', {
                id: id
            }).done(function (data) {
                if ('200' === data) {
                    $('.blog-unfavourite-btn span').removeClass('glyphicon-heart-empty').addClass('glyphicon-heart');
                    $('.blog-unfavourite-btn').removeClass('blog-unfavourite-btn').addClass('blog-favourite-btn');
                }

                if ('403' === data) {
                    alert('没有操作权限');
                }
            })
        }
    }

4、Markdown

书中使用的是Flask-PageDownMarkdown两个库来实现对Markdown功能的支持,但是不够理想,有些Markdown语法并不能很好的支持,例如Flask-PageDown实时预览时并不支持代码块和表格等。最后使用了marked这个库,它是一个全功能的Markdown解析器和编译器,用JavaScript编写,构建速度快,其实就是实时将用Markdown语法编辑的内容转换成对应的HTML预览,但是没有CSS样式的HTML还是有点丑,github-markdown-css是一个不错的选择,可以帮助我们实现github风格的Markdwon预览。既然是要编辑文章那么直接使用HTML里的<textarea>肯定难以实现理想的效果,这里使用了ace,它是一个用JavaScript编写的独立代码编辑器,下载ace-builds/arc-min即可。核心的帮助工具就这些了,接下来就是把他们组合起来,首先看HTML界面主要有编辑和预览两部分:

<!--编辑-->
<div class="col-md-6 markdown-panel">
     <div id="markdown-edit"></div>
</div>
<!--预览-->
<div class="col-md-6 markdown-panel">
     <div id="markdown-preview" class="markdown-body"></div>
</div>

接下来就是编辑器的初始化了:

<script>
    //编辑器配置
    var ace_edit = ace.edit('markdown-edit');
    ace_edit.setTheme('ace/theme/chrome');
    ace_edit.getSession().setMode('ace/mode/markdown');
    ace_edit.renderer.setShowPrintMargin(false);
    //字体大小
    ace_edit.setFontSize(15);
    //自动换行
    ace_edit.setOption('wrap', 'free');

    $("#markdown-edit").keyup(function () {
        // 实现Markdown到HTML的预览
        $("#preview").text(marked(ace_edit.getValue()));
    });
</script>

更多细节可参考markdown_editor.html,看一下效果:

Markdwon

5、数据库

数据库使用的是MySql,同时使用了ORM框架SQLAlchemy把关系数据库的表结构映射到对象上,来简化数据库的操作,Flask有一个Flask-SQLAlchemy扩展可以方便的在程序中使用SQLAlchemy,首先需要指定数据库URL,这一步在config.py完成:

SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@127.0.0.1:3306/niceblog_dev'

然后在工厂函数完成配置。之后就是定义数据模型了,项目中一共定义了6个数据模型:UserRoleBlogCommentFavouriteLabel,都继承db.Model,在数据模型中指定表名称、列名称等信息,例如保存文章信息的Blog

class Blog(db.Model):
    """
    博客数据Model
    """
    __tablename__ = 'blogs'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(128))
    summary = db.Column(db.Text)
    content = db.Column(db.Text)
    content_html = db.Column(db.Text)
    # 发布日期
    publish_date = db.Column(db.DateTime, index=True)
    # 最后的编辑日期
    edit_date = db.Column(db.DateTime, index=True)
    # 外键,和User表对应
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    # 是否是草稿
    draft = db.Column(db.Boolean)
    # 是否禁用评论
    disable_comment = db.Column(db.Boolean, default=False)
    # 被浏览的次数
    views = db.Column(db.Integer, default=0)
    comments = db.relationship('Comment', backref='blog', lazy='dynamic')
    favourites = db.relationship('Favourite', backref='blog', lazy='dynamic')

配置好了数据库、定义好了数据模型,就可以通过如下命令来操作数据库了:

  • db.create_all():创建表
  • db.session.add():插入行、修改行,最后需要执行db.session.commit()
  • db.session.delete():删除行,最后需要执行db.session.commit()
  • 数据模型名.query().查询过滤器.查询执行函数:查询行

常用的查询过滤器有:filter()filter_by()limitoffset()order_by()group_by()
常用的查询执行函数有:all()first()first_or_404()get()get_or_404()count()paginate()

如果在shell中操作数据库,每次都要导入数据库实例和数据模型,如何避免这个问题呢?由于项目使用了Flask-Script命令行解释器,支持自定义命令,可以让Flask-Script的shell命令自动导入特定对象即可:

def make_shell_context():
    return dict(app=app, db=db, User=User, Role=Role, Blog=Blog, Comment=Comment, Favourite=Favourite, Label=Label,
                Permission=Permission)
manager.add_command('shell', Shell(make_context=make_shell_context))

开发中修改数据模型是不可避免的,为了不发生删表重建导致数据丢失的问题,我们需要使用数据库迁移框架,增量式的把数据模型的改变应用到数据库中,我们可以直接使用Flask-Migrate来完成,在Flask-Script集成数据库迁移功能:

migrate = Migrate(app, db)
manager.add_command('db', MigrateCommand)

数据库迁移只要有如下三个命令:

  1. python manage.py db init:创建迁移仓库,初始执行一次即可
  2. python manage.py db migrate --message "initial migration":创建迁移脚本
  3. python manage.py db upgrade:更新数据库

每次修改数据模型后需要更新数据库时执行命令2、3即可。

6、接口开发

api蓝本的目录结构如下:
|-NiceBlog
   |-app/ 主目录
      |-api/ 为移动端提供接口的蓝本
         |-__init__.py 创建蓝本
         |-authentication.py 登录、注册、token检验
         |-blogs.py 文章列表、详情的接口
         |-comments.py 评论相关接口
         |-decorators.py 自定义装饰器
         |-favourites.py 喜欢操作相关的接口
         |-labels.py 文章分类标签接口
         |-responses.py 帮助返回JSON数据

Flask提供的jsonify()函数可以方便的把一个字典转换成JSON串返,例如返回文章分类标签的路由可以这么写:

@api.route('/labels/')
def get_labels():
    labels = Label.query.all()
    data = {'labels': [label.to_json() for label in labels]}
    return jsonify({'error': '', 'code': '200', 'data': data})

to_json()方法是数据模型中Label类的方法,完成数据模型到JSON格式化的序列化字典转换:

    def to_json(self):
        json_label = {
            'id': self.id,
            'name': self.name,
        }
        return json_label

为了保证接口有一定的安全性,不被随意访问,除了登录、注册、以及文章预览的html页面外其他接口都需要一个token参数,token可以在登录后得到,token过期后需要重新请求。由于要对请求携带的token参数校验,可以定义一个before_request钩子,在每次请求前统一完成token的校验:

@api.before_request
def before_request():
    url = request.url
    if url.find('login') == -1 and url.find('register') == -1 and url.find('preview') == -1:
        token = request.args.get('token')
        if token is None:
            return unauthorized('token缺失')
        user = User.verify_auth_token(token)
        if user is None:
            return forbidden('token过期,请重新登录')
        else:
            # g是程序上下文,用作临时存储对象,
            # 保存当前的请求对应的user,每次请求都会更新
            g.current_user = user

测试接口可以使用HTTPie,通过PyCharm在虚拟环境安装HTTPie后,启动Web服务,windows下通过cmd进入虚拟环境目录,执行Scripts\activate激活虚拟环境(退出虚拟环境执行deactivate):

激活虚拟环境

执行登录请求,命令如下:

http POST http://127.0.0.1:5000/api/login/ email==shehuan320@163.com password==123456

响应如下:


登录

使用登录的得到的token请求文章分类标签接口:

http GET http://127.0.0.1:5000/api/labels/ token==eyJhbGciOiJIUzI1NiIsImlhdCI6MTUxODEzOTQwNiwiZXhwIjoxNTE4NzQ0MjA2fQ.eyJpZCI6MX0.EujL1Pb4lg20Bb2QWngop1N79os0LdFWniA8bL4JQHo

响应如下:


文章分类标签

四、安装

以下的安装步骤是基于Windows环境的!

  1. 从Guthub clone NiceBlog到本地
  2. 安装Python 3 的开发环境
  3. 安装PyCharm开发工具,导入项目,建议使用虚拟环境,可直接在 PyCharm 中创建一个虚拟环境,或者使用命令行创建。
  4. 在虚拟环境中安装requestments.txt中的扩展包,直接在 PyCharm 的 Terminal 执行如下命令:
    pip install -r requirements.txt
  5. 安装MySql,创建数据库,并在config.py中替换为自己创建的数据名,并修改用户名和密码
  6. 在 Terminal 执行python manage.py shell切换到 shell 环境,再执行db.create_all()创建数据表
  7. 由于注册账号使用了qq邮箱验证,请在config.py中替换自己的qq邮箱和授权登录密码,并更改管理员邮箱为自己的邮箱。
  8. 执行exit()退出 shell 环境,再执行python manage.py runserver就可以启动 Web 服务了,默认运行在http://127.0.0.0.1:5000
  9. 在浏览器访问http://127.0.0.0.1:5000,你就可以进行注册账号,创建文章等操作了,希望一切顺利吧!

最后附上几张截图:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容