zoukankan      html  css  js  c++  java
  • Python Flask 实现移动端应用接口(API)

    引言


       目前,Web 应用已形成一种趋势:业务逻辑被越来越多地移到客户端,逐渐完善为一种称为富互联网应用(RIA,rich Internet application)的架构。在 RIA 中,服务器的主要功能 (有时是唯一功能)是为客户端提供数据存取服务。在这种模式中,服务器变成了 Web 服务或应用编程接口(API,application programming interface)。 

      Flask 是开发 REST架构(RIA 采用的一种与 Web 服务通信的协议) Web 服务的理想框架,因为 Flask 天生轻量。本文将实际操作,实现一个简单的API。

    一、项目简介


      使用Flask实现一个接口(API),提供给移动端(iOS应用)调用,实现首页数据获取。同时展示了一种较为通用的项目架构及目录结构。

    • 本文客户端iOS代码不做详细说明。
    • Flask部署不做阐述,如需要,可参考之前的文章:Python Flask Web 框架入门
    • 接口功能只是最基本的实现,很多功能需要在真实项目中进行完善:包括身份验证、全量的错误处理、缓存与备份、负载与并发、复杂的数据库操作、数据库迁移、日志、版本迭代管理等等。
    • 服务端部署只是使用到Flask自带的Web服务器。
    • 客户端页面如下,首页接口返回数据包括:轮播图(两个条目)+下方三个分组(每个分组4个条目)

        

    二、环境准备


      1、服务端

    • python包 :python(3.7)、pip、虚拟环境(virtualenv)、Flask、flask-sqlalchemy、pymysql
    • 其他:CentOS7 ECS服务器(本地测试也可以)、MySQL数据库、Git、

      2、其他端

    • 本地开发:Mac、Pycharm、同上的python环境、Navicat(连接数据库)、Git、Postman(接口测试)
    • 客户端:xcode编写iOS客户端

      3、虚拟环境和库

    • 如何创建虚拟环境不做介绍了
    • 在Pycharm中使用已经存在的虚拟环境(从Pycharm偏好设置进入)

         

         

    • 在Pycharm中添加库

         

    三、项目步骤及核心代码


       

     项目目录结构总览(请分清层次)

    • 使用tree命令查看

         

    • Pycharm中查看

          

      (1)app文件夹为业务代码的存放处,包括视图+模型+静态文件,也叫做应用包。

      (2)static、templates、migrations、tests 本文中没有使用到,可跳过

      (3)config.py 和 manage.py是启动应用和配置应用的关键。

      (4)requirements.txt 里面存放当前环境使用到的库,当我们将项目迁移到别的服务器(环境)时,可以通过这个文件,快速导入依赖的所有库。

    pip3 freeze -l > requirements.txt  #导出
    pip3 install -r requirements.txt   #导入

     从 manage.py 开始   

     1 # 启动程序
     2 from app import create_app
     3 
     4 """
     5 development:    开发环境
     6 production:     生产环境
     7 testing:        测试环境
     8 default:        默认环境
     9 
    10 """
    11 # 通过传入当前的开发环境,创建应用实例,不同的开发环境配置有不同的config。这个参数也可以从环境变量中获取
    12 app = create_app('development')
    13 
    14 if __name__ == '__main__':
    15     # flask内部自带的web服务器,只可以在测试时使用
    16     # 应用启动后,在9001端口监听所有地址的请求,同时根据配置文件中的DEBUG字段,设置flask是否开启debug
    17     app.run(host='0.0.0.0', port=9001, debug=app.config['DEBUG'])

    (1)每个flask项目,必须有一个应用实例。这里把实例的创建,推迟到了init中定义的create_app方法(工厂函数)。这样做,可以动态修改配置,给脚本配置应用“留出时间”,还能够创建多个应用,单元测试时也很有用。

    (2)关于debug:在这个模式下,开发服务器默认会加载两个便利的工具:重载器调试器

    • 启用重载器后,Flask 会监视项目中的所有源码文件,发现变动时自动重启服务器。在开 发过程中运行启动重载器的服务器特别方便,因为每次修改并保存源码文件后,服务器都 会自动重启,让改动生效。
    • 调试器是一个基于 Web 的工具,当应用抛出未处理的异常时,它会出现在浏览器中。此时,Web 浏览器变成一个交互式栈跟踪。(本文中,没有用到调试器)

    (3)from app import create_app ,会去app模块中,找去__init__.py ,将其中的对应内容引用进来。

    ②  app模块中 __init__.py  

    from flask_sqlalchemy import SQLAlchemy
    from flask import Flask
    from config import config
    
    # 创建数据库
    db = SQLAlchemy()
    
    def create_app(config_name):
    
        # 初始化
        app = Flask(__name__)
    
        # 导致指定的配置对象:创建app时,传入环境的名称
        app.config.from_object(config[config_name])
    
        # 初始化扩展(数据库)
        db.init_app(app)
    
        # 创建数据库表
        create_tables(app)
    
        # 注册所有蓝本
        regist_blueprints(app)
    
        return app
    
    def regist_blueprints(app):
    
        # 导入蓝本对象
        # 方式一
        from app.api import api
    
        # 方式二:这样,就不用在app/api/__init__.py(创建蓝本时)里面的最下方单独引入各个视图模块了
        # from app.api.views import api
        # from app.api.errors import api
    
        # 注册api蓝本,url_prefix为所有路由默认加上的前缀
        app.register_blueprint(api, url_prefix='/api')
    
    def create_tables(app):
        """
        根据模型,创建表格(可以有两种写法)
        1、模型必须在create_all方法之前导入,模型类声明后会注册到db.Model.metadata.tables属性中
        不导入模型模块,就不会执行模型中的代码,也就无法完成注册。
        2、但是,如果db是在模型模块中创建的,同时在此处 from app.models import db 引用db,则就实现了
        模型和数据库的绑定,不需要再单独导入模型模块了。
        """
        from app.models import Video
        db.create_all(app=app)

    (1)创建应用实例,并且导入config.py文件,来配置app。

    (2)创建数据库实例,然后一定要在create_app中初始化db.init_app(就是和app关联起来)。

    (3)创建数据库表:先创建模型类(在models.py中),然后通过ORM(flask_sqlalchemy)映射为数据库中的表。如上面代码注释所说,一定注意导入模型的时机。

    (4)注册蓝本,此处我们使用的蓝本名称是 api,蓝本实例的创建在api模块的__init_.py 中。

    (5)关于蓝本的补充:

    • 将视图方法模块化,既当大量的视图函数放在一个文件中,很明显是不合适的,最好的方案是根据功能将路由合理的划分到不同的文件中。
    • 转换成应用工厂函数的操作(通过create_app创建应用实例)让定义路由变复杂了,现在应用在运行时创建,只有调用create_app() 之后才能使用 app.route 装饰器,这时定义路由就太晚了。使用蓝本,在蓝本中定义的路由处于休眠状态,直到蓝本注册到应用上之后,它们才真正成为应用的一部分。

    ③  api蓝本模块中的 __init__.py 

    from flask import Blueprint
    
    # 两个参数分别指定蓝本的名字、蓝本所在的包或模块
    api = Blueprint('api', __name__)
    
    """
     导入路由模块、错误处理模块,将其和蓝本关联起来
    
     1、应用的路由保存在包里的 views.py 和 errors.py 模块中
     2、导入这两个模块就能把路由与蓝本关联起来
     3、注意,这些模块在 app/__init__.py 脚本的末尾导入,原因是:
        为了避免循环导入依赖,因为在 app/views.py 中还要导入api蓝本,所以除非循环引用出现在定义 api 之后,否则会致使导入出错。
    
    """
    from app.api import views, error

      配置文件 config.py 

     1 # 配置环境的基类
     2 class Config(object):
     3 
     4     # 每次请求结束后,自动提交数据库中的变动,该字段在flask-sqlalchemy 2.0之后已经被删除了(有bug)
     5     SQLALCHEMY_COMMIT_ON_TEARDOWN = True
     6 
     7     # 2.0之后新加字段,flask-sqlalchemy 将会追踪对象的修改并且发送信号。
     8     # 这需要额外的内存,如果不必要的可以禁用它。
     9     # 注意,如果不手动赋值,可能在服务器控制台出现警告
    10     SQLALCHEMY_TRACK_MODIFICATIONS = False
    11 
    12     # 数据库操作时是否显示原始SQL语句,一般都是打开的,因为后台要日志
    13     SQLALCHEMY_ECHO = True
    14 
    15 
    16 # 开发环境的配置
    17 class DevelopmentConfig(Config):
    18     """
    19     配置文件中的所有的账号密码等敏感信息,应该避免出现在代码中,可以采用从环境变量中引用的方式,比如:
    20     username = os.environ.get('MYSQL_USER_NAME')
    21     password = os.environ.get('MYSQL_USER_PASSWORD')
    22 
    23     本文为了便于理解,将用户信息直接写入了代码里
    24 
    25     """
    26     DEBUG = True
    27     # 数据库URI
    28     SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@172.17.180.2/cleven_development'
    29 
    30     # 也可如下来写,比较清晰
    31     # SQLALCHEMY_DATABASE_URI = "mysql+pymysql://{username}:{password}@{hostname}/{databasename}".format(username="xxxx", password="123456", hostname="172.17.180.2", databasename="cleven_development")
    32 
    33 
    34 # 测试环境的配置
    35 class TestingConfig(Config):
    36 
    37     TESTING = True
    38     SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@172.17.180.3:3306/cleven_test'
    39 
    40 
    41     """
    42     测试环境也可以使用sqlite,默认指定为一个内存中的数据库,因为测试运行结束后无需保留任何数据
    43     也可使用  'sqlite://' + os.path.join(basedir, 'data.sqlite') ,指定完整默认数据库路径
    44     """
    45     # import os
    46     # basedir = os.path.abspath(os.path.dirname(__file__))
    47     # SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or 'sqlite://' 
    48 
    49 
    50 # 生产环境的配置
    51 class ProductionConfig(Config):
    52 
    53     SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@172.17.180.4:3306/cleven_production'
    54 
    55 
    56 # 初始化app实例时对应的开发环境声明
    57 config = {
    58     'development': DevelopmentConfig,
    59     'production': ProductionConfig,
    60     'testing': TestingConfig,
    61     'default': DevelopmentConfig
    62 }

    (1)给配置文件设置一个基类,让不同的配置环境,继承自他。

    (2)关于 flask-sqlalchemy 的一些配置选项列表,不在这里展开了介绍了。

    (3)配置文件中可以写入其他各种配置信息,比如以后使用到的 redis、MongoDB,甚至一些业务代码中使用到的配置相关的“常量”也可以定义在这里(注意代码的整洁)。

      模型文件 models.py 

     1 from app import db
     2 from flask import abort
     3 
     4 class Video(db.Model):
     5     """
     6     视频 Model
     7     """
     8     __tablename__ = 'videos'
     9     # 主键
    10     id = db.Column(db.Integer, primary_key=True)
    11     # 视频id
    12     vid = db.Column(db.String(50))
    13     # 封面图片
    14     coverUrl = db.Column(db.Text)
    15     # 详情描述
    16     desc = db.Column(db.Text)
    17     # 概要
    18     synopsis = db.Column(db.Text)
    19     # 标题
    20     title = db.Column(db.String(100))
    21     # 发布时间
    22     updateTime = db.Column(db.Integer)
    23     # 主题
    24     theme = db.Column(db.String(10))
    25     # 是否已删除?(逻辑)
    26     isDelete = db.Column(db.Boolean, default=False)
    27 
    28     def to_json(self):
    29         """
    30         完成Video数据模型到JSON格式化的序列化字典转换
    31         """
    32         json_blog = {
    33             'id': self.vid,
    34             'coverUrl': self.coverUrl,
    35             'desc': self.desc,
    36             'synopsis': self.synopsis,
    37             'title': self.title,
    38             'updateTime': self.updateTime
    39         }
    40         return json_video

    (1)本文中使用的是“视频”模型,相应表的字段已经声明

    (2)关于 flask-sqlalchemy 的模型属性类型 

         

    (3)常用 SQLAlchemy 列选项

        

    (4)补充:常用 SQLAlchemy 关系选项(本文并没有使用到,可以跳过)

      此处可参阅:flask-sqlalchemy用法详解

         

    ⑥  业务的核心视图函数 views.py 

     1 from flask import make_response, jsonify
     2 from app.api import api
     3 from app.models import getHomepageData
     4 
     5 @api.route('/v1.0/homePage/', methods=['GET', 'POST'])
     6 def homepage():
     7     """
     8      上面 /v1.0/homePage/ 定义的url最后带上"/":
     9      1、如果接收到的请求url没有带"/",则会自动补上,同时响应视图函数
    10      2、如果/v1.0/homePage/这条路由的结尾没有带"/",则接收到的请求里也不能以"/"结尾,否则无法响应
    11     """
    12     response = jsonify(code=200,
    13                        msg="success",
    14                        data=getHomepageData())
    15 
    16     return response
    17     # 也可以使用 make_response 生成指定状态码的响应
    18     # return make_response(response, 200)
    19     

    (1)这个视图,包含一个路由:获取ios应用首页的数据。

    (2)getHomepageData 方法是在models.py中定义的一个函数,用来查询首页数据。

    ⑦  在models.py里添加查询函数

    from app import db
    from flask import abort
    
    class Video(db.Model):
        """
        视频 Model
        """
        __tablename__ = 'videos'
        # 主键
        id = db.Column(db.Integer, primary_key=True)
        # 视频id
        vid = db.Column(db.String(50))
        # 封面图片
        coverUrl = db.Column(db.Text)
        # 详情描述
        desc = db.Column(db.Text)
        # 概要
        synopsis = db.Column(db.Text)
        # 标题
        title = db.Column(db.String(100))
        # 发布时间
        updateTime = db.Column(db.Integer)
        # 主题
        theme = db.Column(db.String(10))
        # 是否已删除?(逻辑)
        isDelete = db.Column(db.Boolean, default=False)
    
        def to_json(self):
            """
            完成Video数据模型到JSON格式化的序列化字典转换
            """
            json_blog = {
                'id': self.vid,
                'coverUrl': self.coverUrl,
                'desc': self.desc,
                'synopsis': self.synopsis,
                'title': self.title,
                'updateTime': self.updateTime
            }
            return json_blog
    
    
    def getHomepageData():
    
        result = {}
        # 获取banner
        banners = Video.query.filter_by(theme='banner')
        result['banner'] = [banner.to_json() for banner in banners]
        # 获取homepage
        first = Video.query.filter_by(theme='hot').all()
        second = Video.query.filter_by(theme='dramatic').all()
        third = Video.query.filter_by(theme='idol').all()
        if len(first) and len(second) and len(third):
            homepage = [{'Hot Broadcast': [item.to_json() for item in first]},
                        {'Dramatic Theater': [item.to_json() for item in second]},
                        {'Idol Theatre': [item.to_json() for item in third]}]
            result['homepage'] = homepage
            return result
        else:
            abort(404)

    (1)上面使用到了flask_sqlalchemy的数据库查询方法,模型类.query即可查询模型对应的表。关于查询的其他常用操作符,只做简单介绍:

          

    (2)abort(404)将请求阻断,并响应flask的errorhandler,在errors.py中实现了errorhandler装饰器装饰的响应函数。回顾一下,errors.py模块,也是在蓝本api中注册过的,所以可以响应abort抛出的错误。

    (3)在下面运行和测试的时候会给出一个完整的json,可做参考。

      错误处理模块 errors.py

    from flask import jsonify
    from . import api
    
    # 使用errorhandler装饰器,只有蓝本才能触发处理程序
    # 要想触发全局的错误处理程序,要用app_errorhandler
    
    @api.app_errorhandler(404)
    def page_not_found(e):
        """这个handler可以catch住所有abort(404)以及找不到对应router的处理请求"""
        return jsonify({'error': '没有找到您想要的资源', 'code': '404', 'data': ''})
    
    
    @api.app_errorhandler(500)
    def internal_server_error(e):
        """这个handler可以catch住所有的abort(500)和raise exeception."""
        return jsonify({'error': '服务器内部错误', 'code': '500', 'data': ''})

              

    四、运行与测试


      

     现在服务端的代码都写完了,关于iOS端,代码很简单,就是一个tableView+SDCycleScrollView+AFN网路请求,不沾代码了。下面开始测试。

     1、在本地,导出所有使用的库:pip3 freeze -l > requirements.txt,然后Git提交代码,服务端同步代码,并且在虚拟环境中安装好所有包:pip3 install -r requirements.txt。

     2、启动应用:python3 manage.py ,如下,成功。

         

     3、启动成功之后,应该在数据库(cleven_development)中创建出了videos这张表,我们用Navicat连接数据库,并添加一些测试数据:

      图片用的是公司项目的资源,打个码~,大家可以随便找点图片,放到自己的服务器上进行测试

         

     4、postman或者浏览器先测试一下 : http://服务器地址:9001/api/v1.0/homePage/,得到数据应该是

      1 {
      2     code = 200;
      3     data =     {
      4         banner =         (
      5                         {
      6                 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/fuyao.jpg";
      7                 desc = "U8d85U7ea7U65e0U654cU597dU770bU7684U4e0dU884c";
      8                 id = D20171117092809862;
      9                 synopsis = "U8d2bU7620U7684U53e4U53bfU57ceU5373U5c06U6380U8d77U4e00U573aU8840U96e8U8165U98ce";
     10                 title = "U7261U4e39U4ed9U5b50U4e4bU7687U5e1dU8bcfU66f0";
     11                 updateTime = 1550122242716;
     12             },
     13                         {
     14                 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/muhouzhiwang.jpg";
     15                 desc = "U73b0U4ee3U793eU4f1aU771fU5b9eU5199U7167Uff0cU7cbeU5f69U65e0U4e0eU4f26U6bd4";
     16                 id = 20181130164518024;
     17                 synopsis = "U59d0U5f1fU604bU73b0U5b9eU7248";
     18                 title = "U7f8eU5bb9U9488";
     19                 updateTime = 1550122242716;
     20             }
     21         );
     22         homepage =         (
     23                         {
     24                 "Hot Broadcast" =                 (
     25                                         {
     26                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/zhengyangmenxiaxiaonvren.jpg";
     27                         desc = "<null>";
     28                         id = 20181017153841718;
     29                         synopsis = "<null>";
     30                         title = "U6b63U9633U95e8U4e0bU5c0fU5973U4eba";
     31                         updateTime = 1553853355;
     32                     },
     33                                         {
     34                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/simeiren.jpg";
     35                         desc = "<null>";
     36                         id = D20171117093709878;
     37                         synopsis = "<null>";
     38                         title = "U601dU7f8eU4eba";
     39                         updateTime = 1553853355;
     40                     },
     41                                         {
     42                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/jiangye.jpg";
     43                         desc = "<null>";
     44                         id = 20181031171606549;
     45                         synopsis = "<null>";
     46                         title = "U5c06U591c";
     47                         updateTime = 1553853355;
     48                     },
     49                                         {
     50                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/aishangnizhiyuwo.jpg";
     51                         desc = "<null>";
     52                         id = 20180628144552415;
     53                         synopsis = "<null>";
     54                         title = "U730eU6bd2U4eba";
     55                         updateTime = 1553853355;
     56                     }
     57                 );
     58             },
     59                         {
     60                 "Dramatic Theater" =                 (
     61                                         {
     62                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/nanfangyouqiaomu.jpg";
     63                         desc = "<null>";
     64                         id = D20171117092809831;
     65                         synopsis = "<null>";
     66                         title = "U5357U65b9U6709U4e54U6728";
     67                         updateTime = 1553853356;
     68                     },
     69                                         {
     70                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/zuihaodeyujian.jpg";
     71                         desc = "<null>";
     72                         id = 20180329103639147;
     73                         synopsis = "<null>";
     74                         title = "U6700U597dU7684U9047U89c1";
     75                         updateTime = 1553853356;
     76                     },
     77                                         {
     78                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/zhaoyao.jpg";
     79                         desc = "<null>";
     80                         id = 20190118091609760;
     81                         synopsis = "<null>";
     82                         title = "U62dbU6447";
     83                         updateTime = 1553853356;
     84                     },
     85                                         {
     86                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/nihewodeqingchengshiguang.jpg";
     87                         desc = "<null>";
     88                         id = 20181107131541789;
     89                         synopsis = "<null>";
     90                         title = "U4f60U548cU6211U7684U503eU57ceU65f6U5149";
     91                         updateTime = 1553853356;
     92                     }
     93                 );
     94             },
     95                         {
     96                 "Idol Theatre" =                 (
     97                                         {
     98                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/langmanxingxing.jpg";
     99                         desc = "<null>";
    100                         id = 20190123094947961;
    101                         synopsis = "<null>";
    102                         title = "U6d6aU6f2bU661fU661f";
    103                         updateTime = 1553853357;
    104                     },
    105                                         {
    106                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/wodetiyulao.jpg";
    107                         desc = "<null>";
    108                         id = 20180124165920835;
    109                         synopsis = "<null>";
    110                         title = "U6211U7684U4f53U80b2U8001U5e08";
    111                         updateTime = 1553853357;
    112                     },
    113                                         {
    114                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/aidesudi.jpg";
    115                         desc = "<null>";
    116                         id = 20180709103825926;
    117                         synopsis = "<null>";
    118                         title = "U7231U7684U901fU9012";
    119                         updateTime = 1553853357;
    120                     },
    121                                         {
    122                         coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/aishangnizhiyuwo.jpg";
    123                         desc = "<null>";
    124                         id = 20180905132122384;
    125                         synopsis = "<null>";
    126                         title = "U7231U4e0aU4f60U6cbbU6108U6211";
    127                         updateTime = 1553853357;
    128                     }
    129                 );
    130             }
    131         );
    132     };
    133     msg = success;
    134 }
    json数据

      里面有一些小问题需要处理,比如<null>这种情况(iOS这边对返回的空对象会解析成NSNull对象,打印出来就是<null>,理论上后端不应该把空对象返回给移动端),咱们就不单独处理了。

     5、xcode打开app,应该可以拿到数据并展示了,good ~ 

    五、总结 


        算是完成了一个简单的移动端应用和Python服务端的通信。当然,里面还有很多问题需要优化,我们也没有加上服务器分发以及uWSGI等部署,同时数据库也就一张表,没有出现连表查询、关系存储等等,所以,只能算是一个双端通信的模型demo,用作大家交流探讨。

      开发移动端API和其他web应用相比,在设计思想和细节上还是有很多不同的。服务端无法全量掌控业务代码,客户端也是独立开发,服务端必须考虑到客户端设备性能、网络状态、平台兼容、统一的数据结构、稳定的访问、文档的提供、友好的用户体验、规范的版本管理等等问题。虽然看上去,服务端只是给客户端手机提供了想要的“资源”,但是,稳定性和规范化,比一般应用要求的还要高很多,换个角度说,为移动端开发API,要求有较高的“容错性”设计。

      后面如果有时间,把demo整理一下,打包上来。

  • 相关阅读:
    Java RunTime Environment (JRE) or Java Development Kit (JDK) must be available in order to run Eclipse. ......
    UVA 1597 Searching the Web
    UVA 1596 Bug Hunt
    UVA 230 Borrowers
    UVA 221 Urban Elevations
    UVA 814 The Letter Carrier's Rounds
    UVA 207 PGA Tour Prize Money
    UVA 1592 Database
    UVA 540 Team Queue
    UVA 12096 The SetStack Computer
  • 原文地址:https://www.cnblogs.com/cleven/p/10979068.html
Copyright © 2011-2022 走看看