preface
Flask is a lightweight Web framework for Python development. How light is it? You can develop a Web service in less than 10 lines, but this is for demonstration purposes only, so today I spent an hour developing an SMS microservice for a production environment. The following is not a sample tutorial, but a service code that is directly available after desensitization in our production environment.
Why to develop SMS micro service?
SMS service we are dependent on the implementation of public cloud, through the API of the public cloud directly call, then why to encapsulate their own?
- Because in the microservice environment, we need to reduce the amount of code duplication. If multiple microservices need to use SMS service, we need to copy the code multiple times. Wrapping the API of public cloud into our own microservice API can reduce the code duplication to one line of Http request.
- Accesskey and Secret calls to the API do not need to be copied to multiple services, reducing security risks.
- Common business logic can be added based on our business needs.
Is there any performance impact of having an extra layer of calls?
An extra layer of calls is an extra network request, but the impact is minimal. We can’t write line-by-line code just because there are too many calls in the object-oriented way.
- The public cloud SMS service is called asynchronously, and error handling is also called asynchronously.
- The invocation of the microservice internal network should be very fast and can be deployed with a virtual machine or with a machine room.
start
First we build the skeleton of the project.
Why build the skeleton of a project?
Flask is so lightweight that specifications such as configuration, routing, and so on need to be defined by the developers themselves. Generally mature development teams have their own development framework, unified configuration, unified development specifications, unified integration of relevant systems, etc. I’m going to share a very simple development skeleton for a production environment.
Create a new project directory and create the app and config Python directories inside. App stores service codes, and config stores configuration codes.
The configuration class
Add the following to config/config.py, configuration design varies from person to person, Flask does nothing to limit it. My design here is to use BaseConfig as the base configuration class to store all the common configurations, while different environments use different subclasses of configuration that only need to change specific values for easy viewing.
If the configured value needs to be injected at run (such as a database connection, etc.), then environment variables can be used in the manner (SECRET_KEY below). I also use OR to provide a default value without environment variables.
import os
class BaseConfig:
"" Configuration base class for common configuration
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(16)
DEBUG = False
TESTING = False
class ProductionConfig(BaseConfig):
Production environment configuration class, used to store production environment configuration
pass
class DevelopmentConfig(BaseConfig):
Development environment configuration class, used to store development environment configuration.
DEBUG = True
class TestingConfig(BaseConfig):
Test environment configuration class for storing the configuration of the development environment
DEBUG = True
TESTING = True
registered_app = [
'app'
]
config_map = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig
}
Copy the code
What does registered_app and config_map do after that? You can do auto-injection, which I’ll talk about later.
Then I add a log configuration. Log configuration is very important. Different development teams often have a standard log configuration template that does not change, so it can be defined directly in the code, or in a configuration file.
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):
Define the log handler for output to the console
console_handler = {
'class': 'logging.StreamHandler'.'formatter': 'default'.'level': log_level,
'stream': 'ext://flask.logging.wsgi_errors_stream'
}
# define the log handler to output to the file
file_handler = {
'class': 'logging.handlers.RotatingFileHandler'.'formatter': 'detail'.'filename': log_file,
'level': log_level,
'maxBytes': log_file_max_bytes,
'backupCount': log_file_max_count
}
# define log output format
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)
Copy the code
The above is a typical Python log configuration method that defines variable parts as parameters (log files, levels, and so on), defines two log handlers (file and console), and only calls this method when used.
Application class
With the configuration defined, we can start creating our Flask application. For those of you who have used Flask, you only need one line of code to create a Flask application.
app = Flask(__name__)
Copy the code
But this is not the way to do it in production. For production and testing purposes, we need a way to get this app object.
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
Copy the code
Here we do several things, one is to register the logging class, two is to load the configuration object, three is to create the instance directory, four is to register the application service.
Why is the registration log in the first line?
Many developers will put logging configuration in the configuration class. This is not a problem, but the sooner you register logging, the sooner your logs will be collected. If logging is configured after the configuration class is loaded, it will not be collected by the logging collector we defined if an error occurs when creating the app.
The method for registering logs can be written like this
def register_logger(a):
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
)
Copy the code
I still get the configuration from the environment variable and call the previous configuration function to configure the log.
Method to load a configuration object.
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]
Copy the code
Get the running environment from the FLASK_ENV environment variable and then load the configuration class according to config_map in the previous configuration class.
The last step is to register our business code.
def register_app(app):
for a in config.registered_app:
module = importlib.import_module(a)
if hasattr(module, 'register'):
getattr(module, 'register')(app)
Copy the code
This uses the registered_app list in the configuration class, which defines the module to load. For microservices, there is usually only one module.
I also need a register method in the app/__init__.py file to perform specific registration operations, such as registering the Flask blueprints.
def register(app):
api_bp = Blueprint('api', __name__, url_prefix='/api')
app.register_blueprint(api_bp)
Copy the code
Why do we have a register method?
Because each business module has its own routing, ORM, or blueprint, etc., this is the business’s own code and must be decoupled from the skeleton. Using a specific method as a specification is (1) easy to customize the code extension, (2) easy to understand by the team, does not require flexible configuration, where convention is greater than configuration. Of course you can have another implementation of your own.
I’ve organized the above code into the application.py module
import os
import importlib
from flask import Flask
from config.logger import config_logger
from config import config
def register_logger(a):
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)
Copy the code
The create_app_by_config method is provided to create the app object directly from the configuration class, mainly so that specific configuration classes can be injected directly during unit testing.
We basically have the skeleton in place, including the basic configuration classes, logging configuration, and application registration mechanism. Then we can run our Flask application.
Develop test
Flask provides the Flask run command to run the test application, but FLASK_APP and FLASK_ENV need to be provided to start the test application.
Write the 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()
Copy the code
Here you create a simple command-line script with Click to launch a test service directly with command-line arguments. Of course, the default parameters can be used directly, either by using Python run.py or by right-clicking in the IDE. The env-file option is also provided, allowing the user to provide a file of environment variables.
Why use environment variable files?
Because many configurations of production and development environments are different, such as public cloud keys, database connections, etc., this information should never be submitted to git or other version control software, so we can create a.env file as follows
ACCESS_KEY=xxx
ACCESS_SECRET=xxx
Copy the code
Add the file to gitignore and load it with –env-file to be used in the development environment instead of typing it manually each time.
The deployment of
For production environment, we will definitely not start the production environment in the way of test. We need tools like Gunicorn to start a formal service. We can also use container technology like Docker to automate the production deployment process.
Write server. Py
from application import create_app
app = create_app()
Copy the code
This is easy, just create a Flask app object, which can then be launched via gunicorn Server :app.
Write a requirements.txt file for automatically installing dependencies. You can write down the dependencies you use later.
flask
flask-restful
click
envparse
gunicorn
Copy the code
Write a Dockerfile file
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"]
Copy the code
You can then start the service container with Docker using the following command.
Docker build-t myapp: 0.1. docker run-d --name myapp -p 80:80 myapp:0.1
Copy the code
At this point, a simple Flask skeleton is complete, and you can see the full project below.
Github Flask skeleton example
Writing business
Flask’s skeleton was built in about 20 minutes. For the development team, the skeleton only needs to be developed once and then cloned for subsequent projects. Let’s write a specific SMS service.
Which public cloud to use?
In real business we may use a single cloud or a mix of clouds. In our actual business, the specific use of public cloud services does not depend on us, but depends on whose price is low, who has more concessions, and who has strong functions. 😄
So we can extract the common message service to write an abstract class. The common features of SMS services include SMS template, signature, receiver, template parameters and so on.
A simple abstract class
class SmsProvider:
def __init__(self, **kwargs):
self.conf = kwargs
def send(self, template, receivers, **kwargs):
pass
Copy the code
Then there is the implementation based on Ali Cloud. The following code is modified according to the official example
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 ' '
Copy the code
Then add the following configuration to BaseConfig, which is the basic configuration of some public cloud APIS. It needs to be loaded by environment variables at runtime. Template_id_map contains the name and ID of the template, which is used to distinguish between different SMS templates, such as verification code, promotion, etc. The name is used as a parameter by the caller, avoiding passing the ID directly.
# 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'}}}}Copy the code
Among them, template ID, signature, App Key and App Secret need to be obtained from Ali Cloud console, and template and signature can only be obtained after review.
In the same way, you can add the HUAWEI cloud API or modify it from the example. However, huawei cloud does not have the SDK, so you need to invoke it through the 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(The '-'.' ')
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 ' '
Copy the code
The final BaseConfig is shown below, where the SMS_PROVIDER configuration specifies the SMS_CONF key and specifies which public cloud service we are using:
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'}}}}Copy the code
Other public clouds can be added in a similar manner.
We then add a method to get the Provider’s singleton object. Flask g objects are used here to register our Provider object as a global singleton.
from flask import g, current_app
from werkzeug.utils import import_string
def create_sms(a):
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(a):
if 'sms' not in g:
g.sms = create_sms()
return g.sms
Copy the code
Once you’ve done that, you can add a view class that uses the Flask-restful library to generate API views.
app/api/sms.py
import logging
from flask_restful import Resource, reqparse
from app.sms import get_sms
# define parameters, refer to 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
Copy the code
Then we define the route.
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')
Copy the code
Finally, register the blueprints in our app module.
app/__init__.py
from app.api import api_bp
# register blueprint
def register(app):
app.register_blueprint(api_bp)
Copy the code
At this point, our SMS micro service is completed. This can be tested and deployed in the same way we did above.
We defined some environment variables that can be loaded from the environment variables file at test time and from the container’s environment variables at run time. Instance is in git because instance is our default Flask instance directory, which is not committed to 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
Copy the code
The runtime is loaded by environment variables
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.1Copy the code
The full project can be viewed here.
Sample project code
We can then do the following tests, paying attention to changing the template ID and environment variables in the configuration, and modifying params based on our template parameters.
conclusion
For the old bird, it may not take an hour to develop this project. For standard online projects, there are still some things missing, such as unit tests. What are your production API services like? Welcome to the discussion!
The short message micro-service here is just a primer. In fact, all public cloud API services can be used in the same way. 1 hour online a micro service, the remaining 7 hours to brush gold 😄.
I am the fire eye gentleman, may my writing dispel the loneliness of the heart.
reference
- palletsprojects.com/p/flask/
- flask-restful.readthedocs.io/en/latest/
- Github.com/wwtg99/flas…
- Ali Cloud SMS API
- Huawei cloud SMS API
- Github.com/wwtg99/sms-…
- zhuanlan.zhihu.com/p/104380919