Phát triển Web với CherryPy + Jinja2 + SqlAlchemy

Bài viết gốc: https://manhhomienbienthuy.bitbucket.io/2015/Aug/30/web-development-with-cherrypy-and-jinja2.html (đã xin phép tác giả 😄)

Bài viết này tiếp nối bài viết trước "Phát triển Web với CherryPy và Jinja2". Nếu bạn chưa đọc bài viết trước, mời bạn tham khảo ở đây

Mở đầu

Ở bài viết trước, tôi đã giới thiệu một cách khái quát về việc phát triển Web với CherryPy và Jinja2. Trong bài viết này tôi sẽ giới thiệu thêm một số kỹ thuật nâng cao hơn, đồng thời giới thiệu 1 ORM (Object Relation Mapper) viết cho Python dùng trong phát triển Web, đó chính là SALchemy.

Phát triển Web

Ở bài viết trước, tôi đã giới thiệu CherryPy cùng với những cách phát triển Web cơ bản nhất. Sau đây, tôi sẽ giới thiệu một số kỹ thuật nâng cao của CherryPy.

Thao tác với JSON

Khi lập trình Web, thường xuyên bạn sẽ gặp trường hợp server cần trả về kết quả kiểu JSON thay vì HTML truyền thống. Ví dụ như bạn lập trình API cho các ứng dụng di động hay đơn giản là trả kết quả cho request từ AJAX.

CherryPy cho phép bạn thực hiện điều này dễ dàng nhờ có decorator @cherypy.tools.json_out(). Ví dụ như sau:

@cherrypy.expose
@cherrypy.tools.json_out()
def some_func(self, *args):
	# Some process
    return {"status": "success", "result": result}

Ví dụ với hàm trên, decorator @cherrypy.expose sẽ cho phép hàm some_func được truy cập thông qua các request HTTP, decorator @cherypy.tools.json_out() sẽ làm hàm trả kết quả là dữ liệu kiểu JSON thay vì HTML mặc định. Kết quả trả về của hàm phải là kiểu dict của Python, ví dụ {"status": "success", "result": result}, khi đó, server Web sẽ trả kết quả cho request là dữ liệu kiểu JSON, giống như {status: success, result: result} cùng với header của gói tin HTTP.

Với decorator này, bạn có thể dễ dàng lập trình các API trả kết quả JSON dùng cho các ứng dụng đi động hay AJAX.

CherryPy còn cung cấp 1 decorator nữa, đó là @cherrypy.tools.json_in(). Decorator này sẽ khiến hàm sẽ nhận tham số dầu vào là dữ liệu kiểu JSON thay vì request HTTP mặc định. Tuy nhiên cần lưu ý rằng, trong phát triển các Web thông thường, các submit từ form hay AJAX đều sử dụng các request HTTP thông thường (GET, POST, ...) chứ ít khi gửi dữ liệu JSON. Trường hợp dữ liệu gửi đi là JSON ít gặp nên tôi sẽ không đi sâu ở đây. Nếu quan tâm, bạn có thể tham khảo ở docs của CherryPy.

I18n cho Jinja2

Có 1 công cụ i18n được viết cho CherryPy ở đây. Tuy nhiên, đây là công cụ được viết cho CherryPy, tức là sẽ được dùng với các Web chỉ dùng CherryPy đơn thuần. Ở trong bài viết này, tôi sử dụng Jinja2 là template engine nên công cụ trên không sử dụng được.

Jinja2 có hỗ trợ phần mở rộng i18n, bạn có thể tham khảo phần mở rộng này ở đây. Tài liệu viết rất đầy đủ những gì Jinja2 hỗ trợ. Tuy nhiên, họ không đưa ra ví dụ cụ thể cách sử dụng nên khá khó khăn với những người mới bắt đầu. Dưới đây, tôi sẽ giới thiệu chi tiết về i18n đối với Jinja2.

Để sử dụng i18n, yêu cầu cần là phải có 1 translator, nó sẽ giúp dịch các đoạn text và Jinja2 sẽ tích hợp chúng vào HTML. Có thể sử dụng GNU gettext để làm translator. Tuy nhiên, tôi sẽ sử dụng babel, vì đơn giản, babel được viết cho Python nên dùng trong Python sẽ dễ dàng hơn. Thông tin về babel bạn có thể tham khảo ở đây.

Trước hết để sử dụng, bạn cần cài đặt babel

$ pip install babel

Ở trong controller chính của app, bạn hãy khởi tại i18n cho Jinja2 tương tự như sau (có thể khởi tạo trong hàm __init__)

    import babel.support

    def __init__(self):
        _tmp_loader = jinja2.FileSystemLoader(searchpath="template")
        translations = babel.support.Translations.load(
            "locale",
            ["vi_VN"]
        )
        self.language = "vi_VN"
        self.tmp_env = jinja2.Environment(
            loader=_tmp_loader,
            extensions=[
                "jinja2.ext.i18n",
                "jinja2.ext.autoescape",
                "jinja2.ext.with_"
            ]
        )
        self.tmp_env.install_gettext_translations(translations)

Với khởi tạo như trên, Jinja2 sẽ tìm kiếm các file template trong thư mục tempalte và tìm cách file ngôn ngữ đã được dịch trong thư mục locale, ngôn ngữ được khởi tạo là vi_VN. Tất cả các khởi tạo này đều có thể thay đổi tùy theo nhu cầu và mục đích trang Web của bạn. Thậm chí, bạn có thể khởi tạo ngôn ngữ tùy theo setting hay IP của người dùng, nhằm giúp họ có được sự thoải mái khi truy cập trang Web của bạn.

Đến đây, sau khi đã khởi tạo translator đầy đủ, bạn có thể sử dụng các thẻ {% trans %} trong file template. Với các thể này, Jinja2 sẽ biết thay thế bằng cách đoạn văn bản tương ứng với ngôn ngữ. Ví dụ.

<h1>{% trans %}hello{% endtrans %}</h1>

Việc cần làm bây giờ là bạn cần định nghĩa các đoạn văn bản tương ứng với các thẻ trans trên. Với babel, công việc này khá là đơn giản. Babel sẽ giúp bạn đưa ra danh sách các cụm từ cần dịch một cách tự động. Trước hết bạn cần cầu hình cho babel

[jinja2: **/template/**.jinja]
encoding = utf-8
[python: source/*.py]
[extractors]
jinja2 = jinja2.ext:babel_extract

Với config trên, babel sẽ tìm thẻ trans ở tất cả các file .jinja và file .py và đưa ra danh sách các cụm từ cần dịch. Sau đó, bạn chạy lệnh dưới đây, babel sẽ tập hợp danh sách thành 1 file (lệnh này sẽ nhận config từ file ./babel.cfg và ghi kết quả ra file ./locale/messages.pot). Bạn có thể thay đổi đường dẫn của chúng nếu muốn.

$ pybabel extract -F ./babel.cfg -o ./locale/messages.pot ./

Bây giờ là đến công việc, gọi là địa phương hóa ngôn ngữ. Câu lệnh dưới đây sẽ khởi tạo ngôn ngữ vi_VN.

$ pybabel init -l vi_VN -d ./locale -i ./locale/messages.pot

Lệnh trên chỉ sử dụng khi khởi tạo, nếu đã từng khởi tạo rồi, thì chỉ cần update nội dung các file dịch với lệnh dưới đây (dùng lệnh trên cũng được nhưng nó sẽ xóa những gì bạn làm trước đó đi và bạn phải làm lại từ đầu):

$ pybabel update -l vi_VN -d ./locale -i ./locale/messages.pot

Các lệnh này sẽ cập nhật kết quả vào file locale\vi_VN\LC_MESSAGES\messages.po. Ở file này, bạn cần quan tâm đến những thông tin tương tự như sau:

msgid: "hello"
msgstr: ""

msgid là text bên trong thẻ transmsgstr là đoạn text tương ứng trong ngôn ngữ cần dịch, ở đây là vi_VN. Việc cần làm lúc này là sửa file trên, thêm lời dịch cho đoạn văn bản. Ví dụ:

msgid: "hello"
msgstr: "xin chào"

Vậy là đã xong, bây giờ bạn cần biên dịch file trên thành 1 file mà Jinja2 có thể hiểu và tích hợp vào HTML:

$ pybabel compile -f -d ./locale

Đến đây là đã hoàn thành công việc. Bây giờ, mỗi thẻ trans trên file template khi hiển thị sẽ được thay thế bằng đoạn văn tương ứng. Ví dụ <h1>{% trans %}hello{% endtrans %}</h1> sẽ được thay bằng <h1>xin chào</h1> như với ví dụ của tôi.

Trên đây tôi chỉ nêu ra 1 ví dụ với ngôn ngữ vi_VN. Bạn có thể mở rộng với các ngôn ngữ khác. Jinja2 không giới hạn bạn có thể dùng bao nhiêu ngôn ngữ. Điều đó là tùy thuộc vào bạn.

Authentication

Nếu trên trang Web của bạn, có những nơi bạn muốn giới hạn với mọi người, chỉ một số người được quyền truy cập. Bạn có thể sử dụng Authentication. CherryPy cung cấp cả 2 hình thức authentication là Basic và Digest. Dưới đây tôi sẽ trình bày về cách sử dụng Digest authentication, Basic cũng tương tự như vậy. Để bảo vệ trong trang Web của bạn, rất đơn giản, hay thêm vào file config như sau (với trang web của tôi, config là conf/app.conf)

[/protected]
tools.auth_digest.on: True
tools.auth_digest.realm: "vpyeu"
tools.auth_digest.get_ha1: cherrypy.lib.auth_digest.get_ha1_dict_plain({"user": "passwd"})
tools.auth_digest.key: "a565c27146791cfb"

Với config như vậy thì khi truy cập /protected trang Web sẽ yêu cầu người dùng điền username và password để xác thực.

CherryPy cho phép bạn có thể config authentication khác nhau với những đường dẫn khác nhau. Tuy nhiên, có 1 hạn chế là nếu nhiều đường dẫn có cùng authentication thì CherryPy không cung cấp config theo regex hay wildcard nên bạn sẽ phải copy paste những đoạn config giống nhau cho các đường dẫn khác nhau.

Sử dụng SQLAlchemy làm ORM

Như đã được giới thiệu, CherryPy là một Web server đơn thuần. Nó không cung cấp bất cứ một template engine nào cũng như ORM nào. Và chúng ta đã sử dụng Jinja2 làm template engine để phát triển Web. Ở bài viết này, tôi sẽ giới thiệu việc sử dụng SQLAlchemy làm ORM trong ứng dụng Web viết bằng CherryPy.

Giới thiệu SQLAlchemy

SQLAlchemy là một bộ công cụ SQL của Python và nó cũng là một ORM (Object Relational Mapper) cung cấp cho những người lập trình ứng dụng đầy đủ sức mạnh và sự mềm dẻo của SQL.

Nó cung cấp một bộ đầy đủ các pattern nổi tiếng. Nó được thiết kế cho việc truy cập cơ sở dữ liệu hiệu quả và hiệu suất cao, đồng thời cho phép lập trình với phong cách Pythonic.

Tại sao lại dùng SQLALchemy

Thực ra, lần đầu tiên dữ dụng ORM cho Python, tôi đã sử dụng SQLAlchemy, lúc đầu nó cũng không dễ dùng cho lắm. Nhưng càng đi sâu tìm hiểu, tôi càng thấy nó thú vị. Nó có rất nhiều tính năng hay. Bạn có thể tham khảo ở đây. Ngoài ra SQLAlchemy được sử dụng với rất nhiều hãng công nghệ nổi tiếng như Reddit, Mozilla. Thực sự nó quá đầy đủ và mạnh mẽ. Nó đủ tốt tới mức tôi không tìm thấy lý do gì để phải tìm kiếm một ORM tốt hơn nữa. Vì vậy tôi đã quyết định dùng SQLAlchemy.

Cài đặt vào sử dụng

Cài đặt SQLAlchemy rất đơn giản thông qua pip. Với virtualenv đã được kích hoạt, bạn có thể cài SQLAlchemy với lệnh sau:

$ pip install sqlalchemy

Sau khi cài đặt, bạn có thể import nó giống như các package Python khác.

Sử dụng SQLAlchemy làm plugin cho CherryPy

Sau khi tham khảo một vài nơi, có nhiều người đã viết các tool dùng để tích hợp SQLAlchemy vào CherryPy. Một ví dụ là ở đây. Thường thì các tool này khá giống nhau. Và bạn có thể sử dụng bất cứ tool nào bạn thích. Tất nhiên, tôi chọn các tool có sẵn vì chúng rất đầy đủ và giúp chúng ta có thêm thời gian để tập trung vào phát triển app. Nếu muốn tìm hiểu sâu hơn cũng như muốn sử dụng tool của riêng mình, bạn hoàn toàn có thể tự phát triển nó, không sao cả. Dưới đây là tool tôi sử dụng. Tôi lưu nó trong file lib/tool/db.py

import cherrypy

__all__ = ['SATool']

class SATool(cherrypy.Tool):
    def __init__(self):
        """
        The SA tool is responsible for associating a SA session
        to the SA engine and attaching it to the current request.
        Since we are running in a multithreaded application,
        we use the scoped_session that will create a session
        on a per thread basis so that you don't worry about
        concurrency on the session object itself.
        This tools binds a session to the engine each time
        a requests starts and commits/rollbacks whenever
        the request terminates.
        """
        cherrypy.Tool.__init__(self, 'on_start_resource',
                               self.bind_session,
                               priority=20)

    def _setup(self):
        cherrypy.Tool._setup(self)
        cherrypy.request.hooks.attach('on_end_resource',
                                      self.commit_transaction,
                                      priority=80)

    def bind_session(self):
        """
        Attaches a session to the request's scope by requesting
        the SA plugin to bind a session to the SA engine.
        """
        session = cherrypy.engine.publish('bind-session').pop()
        cherrypy.request.db = session

    def commit_transaction(self):
        """
        Commits the current transaction or rolls back
        if an error occurs. Removes the session handle
        from the request's scope.
        """
        if not hasattr(cherrypy.request, 'db'):
            return
        cherrypy.request.db = None
        cherrypy.engine.publish('commit-session')

Tool này cần 1 plugin cho CherryPy. Nội dung plugin như sau (lib/plugin/db_plugin.py):

import cherrypy
from cherrypy.process import wspbus, plugins
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker

from lib.model import ORBase
from conf import db

__all__ = ['SAEnginePlugin']

class SAEnginePlugin(plugins.SimplePlugin):
    def __init__(self, bus):
        """
        The plugin is registered to the CherryPy engine and therefore
        is part of the bus (the engine *is* a bus) registery.
        We use this plugin to create the SA engine. At the same time,
        when the plugin starts we create the tables into the database
        using the mapped class of the global metadata.
        """
        plugins.SimplePlugin.__init__(self, bus)
        self.sa_engine = None
        self.session = scoped_session(sessionmaker(autoflush=True,
                                                   autocommit=False))

    def start(self):
        self.bus.log('Starting up DB access')
        self.sa_engine = create_engine(db.url, echo=db.echo)
        #TODO: Make this configurable
        self.create_all()
        self.bus.subscribe("bind-session", self.bind)
        self.bus.subscribe("commit-session", self.commit)

    def stop(self):
        self.bus.log('Stopping down DB access')
        self.bus.unsubscribe("bind-session", self.bind)
        self.bus.unsubscribe("commit-session", self.commit)
        if self.sa_engine:
            #self.destroy_all()
            self.sa_engine.dispose()
            self.sa_engine = None

    def bind(self):
        """
        Whenever this plugin receives the 'bind-session' command, it applies
        this method and to bind the current session to the engine.
        It then returns the session to the caller.
        """
        self.session.configure(bind=self.sa_engine)
        return self.session

    def commit(self):
        """
        Commits the current transaction or rollbacks if an error occurs.
        In all cases, the current session is unbound and therefore
        not usable any longer.
        """
        try:
            self.session.commit()
        except:
            self.session.rollback()
            raise
        finally:
            self.session.remove()

    def create_all(self):
        self.bus.log('Creating database')
        from lib.model.user import User
        ORBase.metadata.create_all(self.sa_engine)

    def destroy_all(self):
        self.bus.log('Destroying database')
        ORBase.metadata.drop_all(self.sa_engine)

Sau khi có tool trên rồi, bạn cần kích hoạt database cho CherryPy bằng config như sau (conf/app.conf):

[/]
tools.db.on: True

Và ở file server.py, bạn cần import và khởi tạo tool này.

from lib.tool import db
from lib.plugin import db_plugin

def __init__(self):
	engine = cherrypy.engine
    cherrypy.tools.db = db.SATool()
    engine.db = db_plugin.SAEngine(engine)
    engine.db.subscribe()

Vậy là bạn đã có thể sử dụng SQLALchemy làm ORM như một plug in cho CherryPy. Bây giờ bạn có thể tạo các model và controller cho chúng.

Tạo model

Tôi sẽ tạo các model trong thư mục lib/model. Trước hết, tôi khởi tạo những ORBase để sử dụng sau này

from sqlalchemy.ext.declarative import declarative_base

__all__ = ["ORBase"]

ORBase = declarative_base()

Bây giờ, thử tạo 1 model user như sau:

import cherrypy
import sqlalchemy
from sqlalchemy import Column, UniqueConstraint, Index
from sqlalchemy.types import Integer, String, Boolean

from lib.model import ORBase

__all__ = ["User"]

class User(ORBase):
    __tablename__ = "users"

    user_id = Column(Integer, nullable=False, primary_key=True)
    name = Column(String(20), nullable=False)
    fullname = Column(String(50))
    email = Column(String(50))
    password = Column(String(128))
    Index(user_id)

Với khởi tạo model user như trên, SQLAlchemy như tạo một table trên database là users với các trường được định nghĩa và đánh index cho trường user_id. Ngoài ra, trong file model này, bạn có thể định nghĩa các thuộc tính cũng như phương thức của model sau này chúng sẽ được sử dụng trên controller.

Tương tác với database

Bây giờ, SQLAlchemy đã được plug vào CherryPy, nên bạn có thể thao tác các query với database thông qua cherrypy.request.db. Dưới đây, tôi sẽ giới thiệu một số thao tác cơ bản với database.

Lấy các bản ghi từ database

cherrypy.request.db.query(User)

Nếu muốn thêm điều kiện nào đó, bạn có thể sử dụng thêm filter. Ví dụ

cherrypy.request.db.query(User).filter(User.user_id < 100)

Kết quả trả về của các câu query trên sẽ là object chứ danh sách các đối tượng tương ứng với các bản ghi trên database. Nếu muốn lấy bản ghi đầu tiên, bạn có thể thêm .first().

cherrypy.request.db.query(User).filter(User.user_id < 100).first()

Nếu muốn sắp xếp, bạn có thể dùng order_by.

cherrypy.request.db.query(User).filter(User.user_id < 100).order_by(User.name.desc())

Nếu muốn sử dụng các điều kiện phức tạp hơn, bạn có thể kết hợp chúng bằng các phép toán logic như and_, or_, not_...

Thêm bản ghi vào database

# khởi tạo object ở đây
new_user = User()
cherrypy.request.db.add(new_user)
cherrypy.request.db.commit()

Xóa bản ghi khỏi database

user = # lấy user từ database
cherrypy.request.db.delete(user)
cherrypy.request.db.commit()

Update bản ghi đang có

user = # lấy user từ trong database.
user.update()
cherrypy.request.db.add(user)
cherrypy.request.db.commit()

Như các bạn đã thấy, mọi thao tác với database đều kết thực với cherrypy.request.db.commit(). Thao tác này sẽ yêu cầu SQLAlchemy thao tác lệnh với database, trong khi các thao trước đó mới chỉ được lưu tạm thời. Nếu không có bước commit này, tất cả các thao tác trước đó sẽ không làm thay đổi database. SQLAlchemy không yêu cầu bạn phải commit từng thao tác của mình. Bạn có thể để một vài thay đổi được commit cùng 1 lúc cũng không sao cả. Điều đó phụ thuộc vào bạn và app của bạn.

Nếu bạn không muốn phải thực hiện commit bằng tay như vậy, bạn có thể config cho SQLAlchemy tự động thực hiện điều đó, bằng cách sử dụng option autocommit=True khi tạo session cho SQLAlchemy.

scoped_session(sessionmaker(autoflush=True, autocommit=False))

Các thông tin về SQLAlchemy bạn có thể xem thêm tại http://www.sqlalchemy.org

Demo

Bạn có thể xem một demo ở đây. Source code của demo này ở đây.

Đây là demo một trang Web đơn giản sử dụng các kiến thức trong bài viết này. Sử dụng Cherrypy, Jinja2 và SQLALchemy. Trang Web có thể chuyển đổi 2 ngôn ngữ Việt và Anh.

Kết luận

Cherrypy cùng với Jinja2 và SQLALchemy tạo thành 1 bộ công cụ rất tốt để phát triển Web. Có thể nó hơi khó khăn khi mới bắt đầu. Nhưng sau khi tìm hiểu kỹ, tôi tìn rằng, bạn sẽ thích nó ngay. Và biết thêm framework mới, bạn có nhiều lựa chọn hơn khi muốn viết một trang Web cho riêng mình, và bạn sẽ tìm được framework thích hợ nhất. Chúc thành công.

Tài liệu tham khảo