Some Complaints About Sanic#
I just had a discussion with Hongjie under the answer to Which Python libraries made you feel regretful for not discovering them earlier? about the pros and cons of Sanic.
I suddenly remembered that our company is one of the few in the country that uses Sanic in a formal production line. As a main promoter, I, as a mediocre documentation engineer, feel it is necessary to talk about a series of pitfalls we encountered while using Sanic.
Main Text#
First, the slogan of the Sanic official site is a Flask Like web framework. This gives many people the illusion that the internal implementation of Sanic is almost identical to Flask, but is that really the case?
Let's first look at a set of Hello World examples.
# Flask
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
return 'Hello World!'
if __name__ == '__main__':
app.run()
# Sanic
from sanic import Sanic
app = Sanic()
@app.route("/")
async def hello_world(request):
return "Hello World!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
Did you notice any differences? Huh? Why does Sanic's view function have an extra parameter?
One of the most typical features of Flask is its concept of Global Variables, such as the global g
variable and the request
variable, which is implemented through a mechanism similar to Thread.Local in werkzeug. Within a request cycle, we can access the current request
variable through from flask import request
. We can also use this mechanism to attach some data for global use.
However, Sanic does not have this concept of Global Variables, meaning that if we want to use the request
variable in our business logic, we need to continuously pass the request
variable until the end of a request cycle.
This way of handling has its pros and cons, but our complaints are just beginning.
Pitfall 1: Extremely Inconvenient for Extensions#
For example, we now have a requirement to write a plugin for colleagues in other departments. In the plugin, we need to add some functionality to the original Request
and Response
classes. In Flask, we can do this easily.
from flask import Request, Response
from flask import Flask
class APIRequest(Request):
pass
class APIResponse(Response):
pass
class NewFlask(Flask):
request_class = APIRequest
response_class = APIResponse
In Flask, we can replace the original Request
and Response
classes by setting the two attributes request_class
and response_class
in the Flask
class.
Just like the code above, we can easily add some extra functionality to Request
and Response
.
But what about Sanic? It's quite painful.
class Sanic:
def __init__(self, name=None, router=None, error_handler=None,
load_env=True, request_class=None,
strict_slashes=False, log_config=None,
configure_logging=True):
# Get name from previous stack frame
if name is None:
frame_records = stack()[1]
name = getmodulename(frame_records[1])
# logging
if configure_logging:
logging.config.dictConfig(log_config or LOGGING_CONFIG_DEFAULTS)
self.name = name
self.router = router or Router()
self.request_class = request_class
self.error_handler = error_handler or ErrorHandler()
self.config = Config(load_env=load_env)
self.request_middleware = deque()
self.response_middleware = deque()
self.blueprints = {}
self._blueprint_order = []
self.configure_logging = configure_logging
self.debug = None
self.sock = None
self.strict_slashes = strict_slashes
self.listeners = defaultdict(list)
self.is_running = False
self.is_request_stream = False
self.websocket_enabled = False
self.websocket_tasks = set()
# Register alternative method names
self.go_fast = self.run
This is the initialization code for the Sanic
class. First, in Sanic
, we cannot easily replace Response
. Secondly, from its __init__
method, we can see that if we want to replace the default Request
, we need to pass a parameter request_class
during initialization. This is quite puzzling; why should this be passed in?
Certainly, we can solve this problem by overriding the __init__
method of the Sanic
class to modify its default parameters.
However, a new problem arises. I always think that when writing components, we should assume that all users of your stuff are, emmmm, not very smart.
Well, since we are providing a plugin, if a user inherits our customized Sanic
class without using super
to call our modified __init__
method, then some interesting issues will arise.
At the same time, the severe coupling within Sanic also makes it difficult for us to build plugins.
Pitfall 2: Severe Internal Coupling#
Now, when we write plugins and want to perform some additional processing when generating Response
, in Flask, we can do this:
from flask import Flask
class NewFlask(Flask):
def make_response(self):
pass
We can directly override the make_response
method in the Flask
class to complete some additional operations when generating our Response
.
This seemingly simple operation becomes quite troublesome in Sanic.
Sanic does not have independent methods for handling data flow at different stages of a request cycle like Flask does, such as dispatch_request
, after_request
, teardown_request
, etc. The handling of Request
and Response
also has a clear boundary, and we can override them as needed.
Sanic wraps the handling of Request
data and Response
data for an entire request cycle in a large handle_request
method.
class Sanic:
#.....
async def handle_request(self, request, write_callback, stream_callback):
"""Take a request from the HTTP Server and return a response object
to be sent back The HTTP Server only expects a response object, so
exception handling must be done here
:param request: HTTP Request object
:param write_callback: Synchronous response function to be
called with the response as the only argument
:param stream_callback: Coroutine that handles streaming a
StreamingHTTPResponse if produced by the handler.
:return: Nothing
"""
try:
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
request.app = self
response = await self._run_request_middleware(request)
# No middleware results
if not response:
# -------------------------------------------- #
# Execute Handler
# -------------------------------------------- #
# Fetch handler from router
handler, args, kwargs, uri = self.router.get(request)
request.uri_template = uri
if handler is None:
raise ServerError(
("'None' was returned while requesting a "
"handler from the router"))
# Run response handler
response = handler(request, *args, **kwargs)
if isawaitable(response):
response = await response
except Exception as e:
# -------------------------------------------- #
# Response Generation Failed
# -------------------------------------------- #
try:
response = self.error_handler.response(request, e)
if isawaitable(response):
response = await response
except Exception as e:
if self.debug:
response = HTTPResponse(
"Error while handling error: {}\nStack: {}".format(
e, format_exc()))
else:
response = HTTPResponse(
"An error occurred while handling an error")
finally:
# -------------------------------------------- #
# Response Middleware
# -------------------------------------------- #
try:
response = await self._run_response_middleware(request,
response)
except BaseException:
error_logger.exception(
'Exception occurred in one of response middleware handlers'
)
# pass the response to the correct callback
if isinstance(response, StreamingHTTPResponse):
await stream_callback(response)
else:
write_callback(response)
This creates a situation where if we want to perform additional operations on a specific stage of the data, we inevitably have to override the large handle_request
method. For example, as mentioned earlier, if we only want to perform some additional operations when generating Response
, in Flask we only need to override the corresponding make_response
method, while in Sanic
, we need to override the entire handle_request
. It can be said that one action affects the whole.
At the same time, unlike Flask, Sanic does not achieve a separation between WSGI layer request handling and framework layer logic. This separation can sometimes bring us a lot of convenience.
For example, I previously wrote a terrible article What You Don't Know About Flask Part 1: Exploring Routes, which mentioned a scenario.
I encountered a strange requirement that needed to support regular expressions in Flask, such as @app.route('/api/(.*?)')
.
This way, when the view function is called, it can pass in the values matched by the regular expression in the URL. However, the Flask router does not support this method by default, so what should we do?
The solution is simple.
from flask import Flask
from werkzeug.routing import BaseConverter
class RegexConverter(BaseConverter):
def __init__(self, map, *args):
self.map = map
self.regex = args[0]
app = Flask(__name__)
app.url_map.converters['regex'] = RegexConverter
After this setup, we can write code according to our requirements.
@app.route('/docs/model_utils/<regex(".*"):url>')
def hello(url=None):
print(url)
As you can see, since Flask's WSGI layer handling is based on werkzeug, it means that sometimes when we deal with URL
or other things involving the WSGI
layer, we only need to override/use the relevant classes or functions provided by werkzeug. At the same time, the operation app.url_map.converters['regex'] = RegexConverter
shows that url_map
is a subclass of the Map
class in werkzeug.routing
, and our operations on it are essentially operations on werkzeug, unrelated to Flask's framework logic.
However, in Sanic, there is no such separation mechanism. For example, in the scenario above:
class Sanic:
def __init__(self, name=None, router=None, error_handler=None,
load_env=True, request_class=None,
strict_slashes=False, log_config=None,
configure_logging=True):
#....
self.router = router or Router()
#....
In Sanic, URL parsing is triggered by an instance of Router()
. If we need to customize our own URL parsing, we need to replace self.router
, which actually modifies Sanic itself, feeling somewhat inappropriate.
At the same time, in the Router
class, if we need to customize our parsing, we need to override the following methods and properties:
class Router:
routes_static = None
routes_dynamic = None
routes_always_check = None
parameter_pattern = re.compile(r'<(.+?)>')
The parameter_pattern
property and several other parsing methods. The Router in Sanic does not have the feature of separating Route
, Parser
, and Formatter
(which is the Converter) like the Router in werkzeug, allowing us to simply restructure and add as needed, as shown in the examples in the article.
This entire section is essentially complaining about the severe internal coupling of Sanic. If we want to implement some additional operations, it can be said that one action affects the whole.
Pitfall 3: Details and Other Issues#
This part has several aspects to discuss.
First, the libraries that Sanic depends on are actually, emmmmmm, not very stable. For example, in October, a bug was triggered where the ujson
it depends on throws exceptions when serializing certain specific data. This issue has been around since 2014 but has not been fixed yet, 2333333. At the same time, in that version, if users wanted to use built-in functions, they could not choose specific parsers. For more details, you can refer to the PR I submitted at that time.
Second, some implementations in Sanic are not rigorous. For example, this article has complained about A Daily Rant: A Small Issue with Sanic.
Third, Sanic currently does not support UWSGI, and when deployed with Gunicorn, it has implemented its own set of Gunicorn Workers. In our production environment, we encounter some mysterious bugs like unknown 504 errors, but we are still investigating (there are also reports that Sanic's server part does not strictly adhere to PEP333 which is the WSGI protocol, = = I will check this out someday).
Conclusion#
The performance of Sanic is indeed impressive. During technical validation, tests showed that under different business logic, its performance can generally be guaranteed to be more than 1.5 times that of Flask. However, based on current usage experience, Sanic still has a long way to go before it can be truly production-ready, whether in terms of internal architecture, surrounding ecology, or other aspects. Everyone can play around with it, but if you want to put it into production, please be prepared to be caught off guard.
Finally, I wish everyone a Happy New Year, Live Long And Prosper!