1 小時上線之用 Flask 開發一個簡訊微服務

火眼君發表於2020-02-07

前言

Flask 是 Python 開發的輕量 Web 框架,有多輕量呢?10 行以內就可以開發一個 Web 服務,不過這隻能用來做演示,今天我就用 1 個小時來開發一個用於生產環境的簡訊微服務。以下是我們生產環境脫敏後直接可用的服務程式碼,絕非示例教程。

為什麼要開發簡訊微服務?

簡訊服務我們都是依賴公有云的實現,通過公有云的 API 直接呼叫,那為什麼還要自己封裝呢?

  • 因為微服務環境下我們要減少程式碼的重複量,如果有多個微服務需要使用簡訊服務,那就要複製多遍程式碼,把公有云的 API 包裝成我們自己的微服務 API 可以將程式碼的複製減少為一行 Http 請求。
  • 呼叫 API 的 accesskey 和 secret 不需要複製給多個服務,減少安全風險。
  • 可以根據我們的業務需求加入共用的業務邏輯。

多了一層呼叫有沒有效能影響?

多了一層呼叫是多了一個網路請求,但是影響微乎其微。我們不可能因為物件導向的方式太多呼叫就寫逐行執行的程式碼吧。

  • 公有云簡訊服務本就是非同步呼叫,錯誤處理也是非同步回撥的方式。
  • 微服務內部網路的呼叫應該是非常快的,可以同虛擬機器部署或者同機房部署。

開始

首先我們建立專案的骨架。

為什麼要建立專案的骨架呢?

因為 Flask 太過於輕量,所以例如配置、路由等規範需要由開發人員自己定義。一般成熟的開發團隊都有自己的一套開發骨架,要統一配置,統一開發規範,統一整合相關係統等。我這裡就分享一套適用於生產環境的非常簡單的開發骨架。

新建一個專案目錄,然後在裡面建立 app 和 config 兩個 Python 目錄。app 用於存放業務相關程式碼,config 用於存放配置相關程式碼。

配置類

config/config.py 中新增如下內容,配置的設計因人而異,Flask 也沒有做任何限制。我這裡的設計是使用 BaseConfig 作為配置基類,存放所有共用的配置,而不同的環境使用不同的配置子類,子類只需要修改特定的值就可以,便於檢視。

如果配置的值需要在執行是注入(如資料庫連線等),則可以使用環境變數的方式(如下面的 SECRET_KEY),我同時使用 or 提供了沒有環境變數的預設值。

import os


class BaseConfig:
    """
    配置基類,用於存放共用的配置
    """
    SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(16)
    DEBUG = False
    TESTING = False


class ProductionConfig(BaseConfig):
    """
    生產環境配置類,用於存放生產環境的配置
    """
    pass


class DevelopmentConfig(BaseConfig):
    """
    開發環境配置類,用於存放開發環境的配置
    """
    DEBUG = True


class TestingConfig(BaseConfig):
    """
    測試環境配置類,用於存放開發環境的配置
    """
    DEBUG = True
    TESTING = True


registered_app = [
    'app'
]

config_map = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'testing': TestingConfig
}
複製程式碼

至於後面的 registered_appconfig_map 有什麼用?可以做自動注入,這個我後面會講。

然後我加一個日誌的配置,日誌的配置非常重要,不同的開發團隊往往有一套規範的日誌配置模版,一般不會改變,所以可以直接定義在程式碼裡,也可以用配置檔案的方式。

config/logger.py

from logging.config import dictConfig


def config_logger(enable_console_handler=True, enable_file_handler=True, log_file='app.log', log_level='ERROR',
                  log_file_max_bytes=5000000, log_file_max_count=5):
    # 定義輸出到控制檯的日誌處理器
    console_handler = {
        'class': 'logging.StreamHandler',
        'formatter': 'default',
        'level': log_level,
        'stream': 'ext://flask.logging.wsgi_errors_stream'
    }
    # 定義輸出到檔案的日誌處理器
    file_handler = {
        'class': 'logging.handlers.RotatingFileHandler',
        'formatter': 'detail',
        'filename': log_file,
        'level': log_level,
        'maxBytes': log_file_max_bytes,
        'backupCount': log_file_max_count
    }
    # 定義日誌輸出格式
    default_formatter = {
        'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
    }
    detail_formatter = {
        'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
    }
    handlers = []
    if enable_console_handler:
        handlers.append('console')
    if enable_file_handler:
        handlers.append('file')
    d = {
        'version': 1,
        'formatters': {
            'default': default_formatter,
            'detail': detail_formatter
        },
        'handlers': {
            'console': console_handler,
            'file': file_handler
        },
        'root': {
            'level': log_level,
            'handlers': handlers
        }
    }
    dictConfig(d)
複製程式碼

上面就是一個典型的 Python 日誌配置方法,把可變的部分定義為引數(日誌檔案、級別等),定義了兩個日誌處理器(檔案和控制檯),使用時只需要呼叫這個方法即可。

應用類

定義好配置,我們就開始建立我們的 Flask 應用了。用過 Flask 的同學知道,建立 Flask 應用只需要一行程式碼。

app = Flask(__name__)
複製程式碼

但這不是生產可用的方式,為了生產和測試方便,我們需要用一個方法獲取這個 app 物件。

def create_app(conf=None):
    # initialize logger
    register_logger()
    # check instance path
    instance_path = os.environ.get('INSTANCE_PATH') or None
    # create and configure the app
    app = Flask(__name__, instance_path=instance_path)
    if not conf:
        conf = get_config_object()
    app.config.from_object(conf)
    # ensure the instance folder exists
    if app.instance_path:
        try:
            os.makedirs(app.instance_path)
        except OSError:
            pass
    # register app
    register_app(app)
    return app
複製程式碼

這裡做了幾個事情,一是註冊日誌類,二是載入配置物件,三是建立 instance 目錄,四是註冊應用業務。

為什麼註冊日誌要放在第一行?

不少開發人員會把日誌的配置放在配置類裡,這個沒太大問題,只是越早註冊日誌,你的日誌就會越早開始收集。如果載入配置類後才配置日誌,那如果建立 app 時報錯就無法被我們定義的日誌收集器收集到了。

註冊日誌的方法可以這樣寫

def register_logger():
    log_level = os.environ.get('LOG_LEVEL') or 'INFO'
    log_file = os.environ.get('LOG_FILE') or 'app.log'
    config_logger(
        enable_console_handler=True,
        enable_file_handler=True,
        log_level=log_level,
        log_file=log_file
    )
複製程式碼

我還是從環境變數裡獲取配置,並呼叫之前的配置函式配置日誌。

載入配置物件的方法。

def get_config_object(env=None):
    if not env:
        env = os.environ.get('FLASK_ENV')
    else:
        os.environ['FLASK_ENV'] = env
    if env in config.config_map:
        return config.config_map[env]
    else:
        # set default env if not set
        env = 'production'
        return config.config_map[env]
複製程式碼

FLASK_ENV 這個環境變數獲取執行的環境,然後根據之前配置類裡的 config_map 獲取對應的配置類,實現配置類的載入。

最後就是註冊我們的業務程式碼。

def register_app(app):
    for a in config.registered_app:
        module = importlib.import_module(a)
        if hasattr(module, 'register'):
            getattr(module, 'register')(app)
複製程式碼

這裡就用到了配置類裡的 registered_app 列表,這裡定義了要載入的模組,對於微服務來說,一般只有一個模組。

我這裡還需要 app/__init__.py 檔案裡有個 register 方法,這個方法來執行具體的註冊操作,例如註冊 Flask 藍圖。

def register(app):
    api_bp = Blueprint('api', __name__, url_prefix='/api')
    app.register_blueprint(api_bp)
複製程式碼

為什麼要搞個 register 方法?

因為每個業務模組有自己的路由、ORM 或藍圖等,這是業務自己的程式碼,必須與骨架解耦。用一個特定的方法作為規範一是便於自定義的程式碼擴充套件,二是便於團隊理解,不需要靈活的配置,這裡約定大於配置。當然你可以有自己的另一套實現。

我把上面的程式碼整理為 application.py 模組

import os
import importlib
from flask import Flask
from config.logger import config_logger
from config import config


def register_logger():
    log_level = os.environ.get('LOG_LEVEL') or 'INFO'
    log_file = os.environ.get('LOG_FILE') or 'app.log'
    config_logger(
        enable_console_handler=True,
        enable_file_handler=True,
        log_level=log_level,
        log_file=log_file
    )


def register_app(app):
    for a in config.registered_app:
        module = importlib.import_module(a)
        if hasattr(module, 'register'):
            getattr(module, 'register')(app)


def get_config_object(env=None):
    if not env:
        env = os.environ.get('FLASK_ENV')
    else:
        os.environ['FLASK_ENV'] = env
    if env in config.config_map:
        return config.config_map[env]
    else:
        # set default env if not set
        env = 'production'
        return config.config_map[env]


def create_app_by_config(conf=None):
    # initialize logger
    register_logger()
    # check instance path
    instance_path = os.environ.get('INSTANCE_PATH') or None
    # create and configure the app
    app = Flask(__name__, instance_path=instance_path)
    if not conf:
        conf = get_config_object()
    app.config.from_object(conf)
    # ensure the instance folder exists
    if app.instance_path:
        try:
            os.makedirs(app.instance_path)
        except OSError:
            pass
    # register app
    register_app(app)
    return app


def create_app(env=None):
    conf = get_config_object(env)
    return create_app_by_config(conf)
複製程式碼

這裡提供了 create_app_by_config 方法用於從配置類直接建立 app 物件,主要是便於單元測試時直接注入特定的配置類。

我們的骨架基本上就成型了,包括了最基礎的配置類、日誌配置和應用序號產生器制。然後就可以執行我們的 Flask 應用了。

開發測試

Flask 提供了 flask run 命令來執行測試應用,不過還需要提供 FLASK_APPFLASK_ENV 兩個環境變數來啟動,這步我們也可以簡化下。

編寫 run.py

import click
from envparse import env
from application import create_app


@click.command()
@click.option('-h', '--host', help='Bind host', default='localhost', show_default=True)
@click.option('-p', '--port', help='Bind port', default=8000, type=int, show_default=True)
@click.option('-e', '--env', help='Running env, override environment FLASK_ENV.', default='development', show_default=True)
@click.option('-f', '--env-file', help='Environment from file', type=click.Path(exists=True))
def main(**kwargs):
    if kwargs['env_file']:
        env.read_envfile(kwargs['env_file'])
    app = create_app(kwargs['env'])
    app.run(host=kwargs['host'], port=kwargs['port'])


if __name__ == '__main__':
    main()
複製程式碼

這裡用 click 建立了一個簡單的命令列指令碼,可以通過命令列引數直接啟動一個測試用服務。當然預設引數直接可用,使用 python run.py 或者 IDE 裡右鍵執行即可。同時,還提供了 env-file 選項,使用者可提供環境變數的檔案。

為什麼要使用環境變數檔案?

因為生產環境和開發環境的許多配置是不同的,例如公有云金鑰,資料庫連線等,這些資訊是絕對不能提交到 git 等版本控制軟體的,所以我們可以建立一個 .env 檔案如下

ACCESS_KEY=xxx
ACCESS_SECRET=xxx
複製程式碼

把這個檔案加入 gitignore 中,然後使用 --env-file 載入這個檔案就可以在開發環境中直接使用了,而不需要每次都手動輸入了。

部署

生產環境我們肯定不會使用測試的方式啟動,需要類似 gunicorn 等工具啟動一個正式服務,我們也可以使用 Docker 等容器技術把生產部署過程自動化。

編寫 server.py

from application import create_app

app = create_app()
複製程式碼

這裡很簡單,建立一個 Flask app 物件即可,然後可以通過 gunicorn server:app 啟動。

編寫 requirements.txt 檔案,用於自動安裝依賴。後期可以把用到的依賴寫進去。

flask
flask-restful
click
envparse
gunicorn
複製程式碼

編寫 Dockerfile 檔案

FROM python:3.8

COPY . /opt
WORKDIR /opt
RUN pip install --no-cache-dir -r requirements.txt
CMD ["gunicorn", "-b", "0.0.0.0:80", "server:app"]
複製程式碼

然後就可以使用如下命令用 Docker 啟動服務容器了。

docker build -t myapp:0.1 .
docker run -d --name myapp -p 80:80 myapp:0.1
複製程式碼

至此,一個簡單的 Flask 骨架就完成了,大家可以在下面看到完整的專案。

Github Flask 骨架示例

編寫業務

上面大概用了 20 分鐘搞了個 Flask 的骨架,對於開發團隊來說,骨架只要開發一次,後續的專案直接克隆就行了。下面我們就來編寫具體的傳送簡訊業務。

使用哪個公有云?

實際業務中我們可能使用單一一個雲,也可能混合使用多個雲。在我們的實際業務中,具體用哪個公有云的服務,不是取決於我們,而是取決於誰的價格低,誰的優惠多,誰的功能強。?

所以我們可以提取簡訊業務的共性寫一個抽象類。簡訊服務的共同點主要有簡訊模版,簽名,接收人,模版引數等。

一個簡單的抽象類

class SmsProvider:

    def __init__(self, **kwargs):
        self.conf = kwargs

    def send(self, template, receivers, **kwargs):
        pass
複製程式碼

然後有基於阿里雲的實現,以下程式碼根據官方示例修改

class AliyunSmsProvider(SmsProvider):

    def send(self, template, receivers, **kwargs):
        from aliyunsdkcore.request import CommonRequest
        client = self.get_client(self.conf['app_key'], self.conf['app_secret'], self.conf['region_id'])
        request = CommonRequest()
        request.set_accept_format('json')
        request.set_domain(self.conf['domain'])
        request.set_method('POST')
        request.set_protocol_type('https')
        request.set_version(self.conf['version'])
        request.set_action_name('SendSms')
        request.add_query_param('RegionId', self.conf['region_id'])
        request.add_query_param('PhoneNumbers', receivers)
        request.add_query_param('SignName', self.conf['sign_name'])
        request.add_query_param('TemplateCode', self.get_template_id(template))
        request.add_query_param('TemplateParam', self.build_template_params(**kwargs))
        return client.do_action_with_exception(request)

    def get_template_id(self, name):
        if name in self.conf['template_id_map']:
            return self.conf['template_id_map'][name]
        else:
            raise ValueError('no template {} found!'.format(name))

    @staticmethod
    def get_client(app_key, app_secret, region_id):
        from aliyunsdkcore.client import AcsClient
        return AcsClient(app_key, app_secret, region_id)

    @staticmethod
    def build_template_params(**kwargs):
        if 'params' in kwargs and kwargs['params']:
            return json.dumps(kwargs['params'])
        else:
            return ''
複製程式碼

然後在 BaseConfig 新增以下配置,是一些公有云 API 的基本配置,需要在執行是通過環境變數載入,其中 template_id_map 裡的內容是模版的名稱和對應的 ID,用於區分不同的簡訊模版,如驗證碼,推廣等,名稱作為引數供呼叫方使用,避免了直接傳遞 ID。

    # SMS config
    SMS_CONF = {
        'aliyun': {
            'provider_cls': 'app.sms.AliyunSmsProvider',
            'config': {
                'domain': 'dysmsapi.aliyuncs.com',
                'version': os.environ.get('ALIYUN_SMS_VERSION') or '2017-05-25',
                'app_key': os.environ.get('ALIYUN_SMS_APP_KEY'),
                'app_secret': os.environ.get('ALIYUN_SMS_APP_SECRET'),
                'region_id': os.environ.get('ALIYUN_SMS_REGION_ID'),
                'sign_name': os.environ.get('ALIYUN_SMS_SIGN_NAME'),
                'template_id_map': {
                    'captcha': 'xxx'
                }
            }
        }
    }
複製程式碼

其中模版 ID,簽名,App Key,App Secret 需要在阿里雲控制檯獲取,模版和簽名需要稽核後才能獲得。

同樣的方法可以新增華為雲的 API,也可直接從示例修改,只是華為雲暫時沒有 SDK,需要通過 API 呼叫,大同小異。

class HuaweiSmsProvider(SmsProvider):

    def send(self, template, receivers, **kwargs):
        header = {'Authorization': 'WSSE realm="SDP",profile="UsernameToken",type="Appkey"',
                  'X-WSSE': self.build_wsse_header(self.conf['app_key'], self.conf['app_secret'])}
        form_data = {
            'from': self.conf['sender'],
            'to': receivers,
            'templateId': self.get_template_id(template),
            'templateParas': self.build_template_params(**kwargs),
        }
        r = requests.post(self.conf['url'], data=form_data, headers=header, verify=False)
        return r

    def get_template_id(self, name):
        if name in self.conf['template_id_map']:
            return self.conf['template_id_map'][name]
        else:
            raise ValueError('no template {} found!'.format(name))

    @staticmethod
    def build_wsse_header(app_key, app_secret):
        now = time.strftime('%Y-%m-%dT%H:%M:%SZ')
        nonce = str(uuid.uuid4()).replace('-', '')
        digest = hashlib.sha256((nonce + now + app_secret).encode()).hexdigest()
        digest_base64 = base64.b64encode(digest.encode()).decode()
        return 'UsernameToken Username="{}",PasswordDigest="{}",Nonce="{}",Created="{}"'.format(app_key, digest_base64,
                                                                                                nonce, now)

    @staticmethod
    def build_template_params(**kwargs):
        if 'params' in kwargs and kwargs['params']:
            return json.dumps(list(kwargs['params'].values()))
        else:
            return ''
複製程式碼

也是新增配置,最後的 BaseConfig 如下所示,其中 SMS_PROVIDER 配置指定 SMS_CONF 的鍵,指定我們現在使用的是哪個公有云服務:

class BaseConfig:
    SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(16)
    DEBUG = False
    TESTING = False

    # SMS config
    SMS_PROVIDER = os.environ.get('SMS_PROVIDER')
    SMS_CONF = {
        'aliyun': {
            'provider_cls': 'app.sms.AliyunSmsProvider',
            'config': {
                'domain': 'dysmsapi.aliyuncs.com',
                'version': os.environ.get('ALIYUN_SMS_VERSION') or '2017-05-25',
                'app_key': os.environ.get('ALIYUN_SMS_APP_KEY'),
                'app_secret': os.environ.get('ALIYUN_SMS_APP_SECRET'),
                'region_id': os.environ.get('ALIYUN_SMS_REGION_ID'),
                'sign_name': os.environ.get('ALIYUN_SMS_SIGN_NAME'),
                'template_id_map': {
                    'captcha': 'xxx'
                }
            }
        },
        'huawei': {
            'provider_cls': 'app.sms.HuaweiSmsProvider',
            'config': {
                'url': os.environ.get('HUAWEI_URL'),
                'app_key': os.environ.get('HUAWEI_SMS_APP_KEY'),
                'app_secret': os.environ.get('HUAWEI_SMS_APP_SECRET'),
                'sender': os.environ.get('HUAWEI_SMS_SENDER_ID'),
                'template_id_map': {
                    'captcha': 'xxx'
                }
            }
        }
    }
複製程式碼

其他的公有云也可以通過類似的方式新增。

然後我們新增一個方法,獲取 Provider 的單例物件。這裡使用 Flask 的 g 物件,把我們的 Provider 物件註冊成全域性的單例物件。

from flask import g, current_app
from werkzeug.utils import import_string


def create_sms():
    provider = current_app.config['SMS_PROVIDER']
    sms_config = current_app.config['SMS_CONF']
    if provider in sms_config:
        cls = sms_config[provider]['provider_cls']
        conf = sms_config[provider]['config']
        sms = import_string(cls)(**conf)
        return sms
    return None


def get_sms():
    if 'sms' not in g:
        g.sms = create_sms()
    return g.sms
複製程式碼

這些都完成後,就可以新增一個檢視類,這裡用到了 Flask-Restful 庫,生成 API 檢視。

app/api/sms.py

import logging
from flask_restful import Resource, reqparse
from app.sms import get_sms


# 定義引數,參考 https://flask-restful.readthedocs.io/en/latest/reqparse.html
parser = reqparse.RequestParser(bundle_errors=True)
parser.add_argument('receivers', help='Comma separated receivers.', required=True)
parser.add_argument('template', help='Notification template name.', required=True)
parser.add_argument('params', help='Notification template params.', type=dict)


class Sms(Resource):

    def post(self):
        args = parser.parse_args()
        sms = get_sms()
        try:
            res = sms.send(**args)
        except Exception as e:
            logging.error(e)
            return {'message': 'failed'}, 500
        if res.status_code < 300:
            return {'message': 'send'}, 200
        else:
            logging.error('Send sms failed with {}'.format(res.text))
            return {'message': 'failed'}, 500
複製程式碼

然後我們定義路由。

app/api/__init__.py

from flask import Blueprint
from flask_restful import Api
from app.api.health import Health
from app.api.sms import Sms


api_bp = Blueprint('api', __name__, url_prefix='/api')
api = Api(api_bp)

api.add_resource(Sms, '/sms')
複製程式碼

最後記得在我們的應用 app 模組裡註冊藍圖。

app/__init__.py

from app.api import api_bp


# register blueprint
def register(app):
    app.register_blueprint(api_bp)
複製程式碼

至此,我們的簡訊微服務就完成了。可以通過我們上面的方法進行測試和部署。

其中我們定義了一些環境變數,在測試時可通過環境變數檔案載入,執行時可通過容器的環境變數載入。放在 instance 目錄下是因為 instance 是我們預設的 Flask 例項目錄,這個目錄是不會提交到 git 裡的。

instance/env

SMS_PROVIDER=huawei
HUAWEI_URL=https://rtcsms.cn-north-1.myhuaweicloud.com:10743/sms/batchSendSms/v1
HUAWEI_SMS_APP_KEY=aaa
HUAWEI_SMS_APP_SECRET=bbb
HUAWEI_SMS_SENDER_ID=ccc
複製程式碼

執行時通過環境變數載入

docker run -d --name sms -p 80:80 \
-e "SMS_PROVIDER=aliyun" \
-e "ALIYUN_SMS_APP_KEY=aaa" \
-e "ALIYUN_SMS_APP_SECRET=bbb" \
-e "ALIYUN_SMS_REGION_ID=cn-hangzhou" \
-e "ALIYUN_SMS_SIGN_NAME=ccc" \
myapp:0.1
複製程式碼

完整的專案可在這裡檢視。

示例專案程式碼

然後我們可以做如下測試,注意修改配置中的模版 ID 和環境變數,並根據自己的模版引數修改 params。

1 小時上線之用 Flask 開發一個簡訊微服務

1 小時上線之用 Flask 開發一個簡訊微服務

結語

對於老鳥來說,開發這個專案,可能根本不需要 1 個小時。對於規範的線上專案來說,還是缺少一些東西的,例如單元測試。大家的生產 API 服務是怎麼樣的?歡迎討論!

這裡的簡訊微服務只是拋磚引玉,其實所有的公有云 API 服務都可以一樣的套用。1 小時上線一個微服務,剩下 7 小時划水刷掘金?。

我是火眼君,願我的寫作,驅散心靈的孤單。

參考

相關文章