Nhấn vào theo dõi chúng tôi để cập nhật các bài viết mới nhất. Theo dõi!

[Flask] – Phần 9: Phân trang

Hướng dẫn lập trình Flask – Phần 9: Phân trang
Bài viết liên quan:
Trong Phần 8, chúng ta đã cập nhật cơ sở dữ liệu để xây dựng chức năng “follower” tương tự như các mạng xã hội. Để tiếp tục hoàn thiện ứng dụng blog, chúng ta cần thay thế các bài viết “giả” mà chúng ta tạo ra trực tiếp trong mã nguồn từ bài đầu tiêu trong loạt bài học của chúng ta. Kể từ phần này, ứng dụng sẽ có chức năng để các user tạo ra các bài viết mới và đăng lên trang chủ cũng như trang hồ sơ cá nhân của họ.

Chức năng tạo bài viết

Trước hết, chúng ta cần tạo một form đơn giản để các user có thể nhập vào các bài viết. Chúng ta sẽ tạo một form như sau:
  • app/forms.py: form đăng bài viết
class PostForm(FlaskForm):
    post = TextAreaField('Your post', validators=[
        DataRequired(), Length(min=1, max=140)])
    submit = SubmitField('Submit')
Tiếp theo, chúng ta sẽ dùng form này trong template của trang chủ:
  • app/templates/index.html: Tích hợp form đăng bài viết vào trang chủ
{% extends "base.html" %}
 
{% block content %}
    <h1>Hi, {{ current_user.username }}</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.post.label }}<br>
            {{ form.post(cols=32, rows=4) }}<br>
            {% for error in form.post.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
    {% for post in posts %}
    <p>
    {{ post.author.username }}: <b>{{ post.body }}</b>
    </p>
    {% endfor %}
{% endblock %}
Cách xử lý form để tạo bài viết trong template cũng tương tự như các form khác. Và cuối cùng, chúng ta cần cập nhật mã để xử lý form này trong hàm hiển thị của trang chủ:
  • app/routes.py: hàm hiển thị cho form để đăng bài viết
from app.forms import PostForm
from app.models import Post
 
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    form = PostForm()
    if form.validate_on_submit():
        post = Post(body=form.post.data, author=current_user)
        db.session.add(post)
        db.session.commit()
        flash('Your post is now live!')
        return redirect(url_for('index'))
    posts = [
        {
            'author': {'username': 'cuong'},
            'body': 'Chúng ta đang học phần 9!'
        },
        {
            'author': {'username': 'phu'},
            'body': 'Flask đâu có khó!'
        }
    ]
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)
Trong đoạn mã trên, chúng ta đã thực hiện các thay đổi trong hàm hiển thị cho trang chủ như sau:
  • Thêm tham chiếu đến các lớp Post và PostForm
  • Cho phép hàm hiển thị chấp nhận cả các yêu cầu POST thay vì chỉ có GET như trước đây để có thể dùng các dữ liệu về bài viết được user gởi đến trong form.
  • Lưu các bài viết vào cơ sở dữ liệu.
  • Thêm tham số form vào phương thức render_template() để nó có thể hiển thị trường nhập liệu văn bản.
Chúng ta sẽ ngừng một chút ở đây để đi sâu hơn vào một chi tiết trong cách xử lý dữ liệu được gởi qua yêu cầu POST trong đoạn mã ở trên. Bạn có để ý rằng sau khi chúng ta lưu bài viết vào cơ sở dữ liệu, chúng ta sử dụng hàm chuyển hướng đến trang chủ để thoát khỏi hàm hiển thị không (bằng cách gọi hàm redirect(url_for(‘index’))? Tại sao chúng ta cần phải làm như vậy thay vì cứ để hàm hiển thị thi hành cho đến cuối cùng bởi vì nó cũng sẽ hiển thị trang chủ?
Trong thực tế, đây là một cách xử lý rất phổ biến cho các yêu cầu POST. Lý do là để tránh các rắc rối với chức năng “refresh” (làm mới trang Web) trong các trình duyệt. Khi bạn bấm vào nút “Refresh” trên trình duyệt, nó sẽ phát lại yêu cầu cuối cùng đã được gởi tới máy chủ. Do đó, nếu yêu cầu cuối cùng là yêu cầu dạng POST và được máy chủ trả lời như bình thường, chức năng “Refresh” trên trình duyệt sẽ gởi lại yêu cầu POST đó tới máy chủ. Bởi vì điều này bất thường, cho nên trình duyệt sẽ hỏi user để xác nhận rằng đây là một yêu cầu trùng lặp, nhưng phần lớn các user sẽ không hiểu (hoặc không để ý ý nghĩa của câu hỏi này) và cho phép trình duyệt gởi đi và hậu quả là sẽ tạo ra các nội dung trùng lặp trong ứng dụng. Nhưng nếu máy chủ chuyển hướng (redirect) sau khi một yêu cầu POST được thực hiện thành công, trình duyệt phải gởi một yêu cầu GET đến trang đã được chỉ định trong lệnh chuyến hướng ngay sau đó. Vì vậy, trong trường hợp này, yêu cầu cuối cùng được thực hiện bởi trình duyệt là GET chứ không phải POST. Vì thế, nếu có refresh thì user sẽ không bị ảnh hưởng bởi yêu cầu POST trước đó.
Mẹo nhỏ này được gọi là mẫu thiết kế (pattern) Post/Redirect/Get. Nó giúp chúng ta tránh được tác dụng phụ khi có lệnh refresh sau khi trình duyệt vừa mới gởi đi một yêu cầu POST để gởi dữ liệu về máy chủ.

Hiển thị các bài viết

Bạn có nhớ là chúng ta tạo ra các bài viết giả và vẫn còn sử dụng chúng cho đến thời điểm này hay không? Chúng ta đã tạo ra các bài viết giả này dưới dạng một danh sách đơn giản trong mã nguồn của hàm hiển thị cho trang chủ:
posts = [
        { 
            'author': {'username': 'cuong'}, 
            'body': 'Chúng ta đang học phần 9!' 
        },
        { 
            'author': {'username': 'phu'}, 
            'body': 'Flask đâu có khó!' 
        }
    ]
Nhưng bây giờ chúng ta đã có phương thức followed_posts() trong lớp User để trả về một danh sách các bài viết mà user cần tìm. Vì vậy, chúng ta có thể thay thế các bài viết giả này bằng danh sách thật từ cơ sở dữ liệu:
  • app/routes.py: Hiển thị các bài viết thật trên trang chủ
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    ...
    posts = current_user.followed_posts().all()
    return render_template("index.html", title='Home Page', form=form,
                           posts=posts)
Phương thức followed_posts() trong lớp User trả về một đối tượng truy vấn SQLAlchemy đã được thiết lập để lấy các bài viết mà user quan tâm từ cơ sở dữ liệu. Truy vấn này sẽ được kích hoạt khi chúng ta gọi hàm all() và trả về một danh sách các bài viết. Vì vậy, chúng ta sẽ có kết quả tương tự như danh sách các bài viết giả mà chúng ta đã sử dụng và không cần phải thay đổi phần còn lại của template.

Tính năng tìm kiếm user

Chắc chắn là bạn cũng thấy được rằng cho đến thời điểm này, các user sẽ gặp khó khăn khi muốn theo dõi các user khác vì ứng dụng không có chức năng hỗ trợ để trả về danh sách các user đã đăng ký. Vì vậy, chúng ta sẽ thực hiện một số thay đổi để tạo ra tính năng này.
Chúng ta sẽ tạo ra một trang mới trong ứng dụng gọi là trang “Explore”. Trang này sẽ hoạt động tương tự như trang chủ nhưng nó sẽ hiển thị danh sách các bài viết từ tất cả các user trong hệt thống thay vì danh sách các bài viết của các user được theo dõi. Sau đây là hàm hiển thị cho chức năng này:
  • app/routes.py: Hàm hiển thị cho trang Explore
{% extends "base.html" %}
 
{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% if form %}
    <form action="" method="post">
        ...
    </form>
    {% endif %}
    ...
{% endblock %}
Chúng ta cũng sẽ thêm một liên kết đến trang mới này vào thanh định hướng:
  • app/templates/base.html: liên kết đến trang explore từ thanh định hướng
<a href="{{ url_for('index') }}">Home</a>
<a href="{{ url_for('explore') }}">Explore</a>
Bạn còn nhớ chúng ta đã sử dụng template con _post.html để hiển thị các bài viết trong trang hồ sơ cá nhân trong Phần 6 không? Đó là một template nhỏ được tham chiếu trong template của trang hồ sơ cá nhân và cũng có thể được sử dụng bởi các template khác nếu cần thiết. Chúng ta sẽ thay đổi template này một chút để hiển thị username của tác giả bài viết dưới dạng các liên kết:
  • app/templates/_post.html: Hiển thị liên kết đến tác giả các bài viết
<table>
        <tr valign="top">
            <td><img src="{{ post.author.avatar(36) }}"></td>
            <td>
                <a href="{{ url_for('user', username=post.author.username) }}">
                    {{ post.author.username }}
                </a>
                :<br>{{ post.body }}
            </td>
        </tr>
    </table>
Đến đây, chúng ta có thể sử dụng template con này trong trang chủ và trang explore:
  • app/templates/index.html: sử dụng template _post.html
...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    ...
Template này cần có một biến là post, và trong template index.html, biến này được cung cấp qua vòng lặp.
Với các thay đổi nhỏ này, tính khả dụng của ứng dụng được nâng cao đáng kể. Các user có thể đến trang Explore để đọc các bài viết từ các user bất kỳ trong ứng dụng. Nếu cần theo dõi một user nào từ trang này, họ chỉ cần bấm vào username để mở trang hồ sơ cá nhân tương ứng và kích hoạt chức năng theo dõi từ đó.
Bạn có thể chạy ứng dụng và làm quen với các chức năng mới mà chúng ta vừa thêm vào:

Chức năng phân trang các bài viết

Ứng dụng của chúng ta đã tương đối tốt. Tuy vậy, việc hiển thị tất cả các bài viết được theo dõi trên trang chủ có thể sớm trở thành một vấn đề quan trọng. Điều gì sẽ xảy ra nếu một user có một nghìn hay một triệu bài viết trong danh sách theo dõi? Việc quản lý một danh sách lớn sẽ làm ứng dụng chạy chậm một cách đáng kể và không hiệu quả.
Để giải quyết vấn đề này, chúng ta sẽ tiến hành phân trang cho các bài viết. Nói cách khác, chúng ta sẽ chỉ hiển thị một phần nhỏ các bài viết tại một thời điểm và cung cấp liên kết để truy cập toàn bộ các bài viết còn lại. Flask-SQLAlchemy hỗ trợ cho tác vụ này thông qua hàm truy vấn có sẵn là paginate(). Ví dụ như nếu chúng ta chỉ muốn lấy danh sách hai mươi bài viết đầu tiên của user, chúng ta sẽ thay thế hàm all() ở cuối truy vấn như sau:
>>> user.followed_posts().paginate(1, 20, False).items
Hàm paginate() có thể được sử dụng bởi bất kỳ đối tượng truy vấn nào từ thư viện Flask-SQLAlchemy. Nó cần ba tham số như sau:
  • Số thứ tự của trang, bắt đầu từ 1
  • Số đơn vị trên một trang
  • Cờ hiệu lỗi (error flag). Nếu là True, hàm này sẽ trả về lỗi 404 nếu trang được yêu cầu không tồn tại (out of range). Nếu là False, nó sẽ trả về một danh sách rỗng nếu trang được yêu cầu không tồn tại.
Hàm paginate() sẽ trả về một đối tượng Pagination. Thuộc tính items của đối tượng này chứa danh sách các phần tử trong trang được yêu cầu. Ngoài ra còn có một vài thuộc tính hữu ích khác mà chúng ta sẽ thảo luận sau này.
Tiếp theo, chúng ta sẽ tìm cách để tạo chức năng phân trang trong hàm hiển thị index(). Đầu tiên, chúng ta cần thiết lập giá trị để cho biết có bao nhiêu phần tử trong mỗi trang:
  • config.py: tham số để thiết lập cho số bài viết trong mỗi trang
class Config(object):
    ...
    POSTS_PER_PAGE = 3
Chúng ta cần tập thói quen để đặt các tham số có thể gây ảnh hưởng đến toàn bộ ứng dụng vào file cấu hình vì chúng ta chỉ cần đến một nơi duy nhất để thay đổi chúng. Dĩ nhiên trong thực tết, chúng ta sẽ dùng giá trị khác để có nhiều hơn ba bài viết cho mỗi trang, nhưng để dễ kiểm tra, chúng ta sẽ tạm thời thiết lập giá trị này.
Tiếp theo, chúng ta cần quyết định sử dụng số thứ tự của trang như thế nào trong URL của ứng dụng. Cách phổ biến là dùng các tham số trong địa chỉ (query string) để chỉ định số thứ tự trang với giá trị mặc định là 1 nếu không có nó. Sau đây là một số ví dụ về các URL theo định dạng mà chúng ta sẽ sử dụng:
  • Trang 1, ngầm định: http://localhost:5000/index
  • Trang 1, chỉ định: http://localhost:5000/index?page=1
  • Trang 3: http://localhost:5000/index?page=3
Để truy cập các tham số này, chúng ta sử dụng đối tượng request.args do Flask cung cấp. Chúng ta đã dùng đối tượng này trong Phần 5 khi chúng ta xây dựng URL cho form đăng nhập với Flask-Login và có bao gồm tham số next.
Chúng ta sẽ cập nhật hàm hiển thị cho trang chủ và trang explore để sử dụng chức năng phân trang như sau:
  • app/routes.py: tạo phân trang cho trang chủ và Explore
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items)
 
@app.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    return render_template("index.html", title='Explore', posts=posts.items)
Với các thay đổi này, các hàm hiển thị trên sẽ biết được trang nào cần được hiển thị. Bằng cách sử dụng giá trị của tham số page trong URL (có giá trị mặc định là 1) kết hợp với phương thức paginate(), hàm trên chỉ tìm và hiển thị trang cần thiết. Tham số cấu hình POST_PER_PAGE trong đối tượng app.config sẽ quyết định kích thước của trang.
Nếu để ý, bạn sẽ thấy các thay đổi của chúng ta không gây ra các hiệu ứng phụ và ảnh hưởng đến các phần khác ngoài ý muốn. Đó là vì chúng ta đã thiết kế và xây dựng mỗi thành phần tương đối độc lập với nhau và không dựa trên giả định các thành phần khác hoạt động như thế nào. Phương pháp này cho phép chúng ta tạo ra các thành phần và ứng dụng dễ mở rộng và kiểm tra, ít gây lỗi hoặc bị trục trặc trong quá trình vận hành.
Bạn hãy chạy thử ứng dụng và thử chức năng phân trang. Trước hết, hãy chắc rằng bạn tạo ra nhiều hơn ba bài viết. Điều này rất dễ kiểm tra vì trang Explore có hiển thị bài viết từ tất cả user. Nếu bạn vào trang này lần đầu tiên, bạn sẽ chỉ thấy ba bài viết mới nhất, nếu bạn muốn thấy ba bài viết tiếp theo, hãy nhập vào URL: http://localhost:5000/explore?page=2 trong thanh địa chỉ của trình duyệt.

Di chuyển giữa các trang

Thay đổi tiếp theo mà chúng ta sẽ làm là thêm các liên kết vào cuối danh sách các bài viết để user có thể di chuyển đến trang tiếp theo hoặc trang trước đó. Bạn có nhớ rằng giá trị trả về của hàm paginate() là một đối tượng thuộc lớp Pagination trong thư viện Flask-SQLAlchemy? Hiện giờ, chúng ta đang sử dụng thuộc tính items của đối tượng này, và như đã nói ở trên, nó chứa một danh sách các phần tử trong trang được chọn. Tuy vậy, đối tượng này còn có vài thuộc tính hữu ích khác mà chúng ta có thể sử dụng để tạo ra các liên kết cho việc phân trang:
  • has_next: có giá trị True nếu có ít nhất một trang phía sau trang hiện tại
  • has_prev: có giá trị True nếu có ít nhất một trang phía trước trang hiện tại
  • next_num: số thứ tự của trang tiếp theo
  • prev_num: số thứ tự của trang trước đó
Với bốn thuộc tính này, chúng ta có thể tạo ra các liên kết đến các trang trước và sau trang hiện tại và đưa chúng vào các template để hiển thị:
  • app/routes.py: Liên kết đến các trang trước và sau
@app.route('/', methods=['GET', 'POST'])
@app.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    ...
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('index', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('index', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('index.html', title='Home', form=form,
                           posts=posts.items, next_url=next_url,
                           prev_url=prev_url)
 
 @app.route('/explore')
 @login_required
 def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('explore', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('explore', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template("index.html", title='Explore', posts=posts.items,
                          next_url=next_url, prev_url=prev_url)
Các biến next_url và prev_url trong hai hàm hiển thị trên sẽ được gán các địa chỉ được trả về bởi hàm url_for() khi nào có một trang phía sau hoặc phía trước trang hiện hành. Nếu trang hiện hành là trang cuối cùng hoặc đầu tiên, giá trị của thuộc tính has_next hoặc has_prev của đối tượng Pagination sẽ là False và khi đó, liên kết sẽ được gán giá trị None và không được hiển thị.
Có một đáng lưu ý về hàm url_for() là nó cho phép bạn sử dụng các tham số bất kỳ dưới dạng các từ khóa (ví dụ như tham số page trong đoạn mã trên) và nếu các tham số này không được dùng trực tiếp trong URL, Flask sẽ thêm các tham số này vào phần query string trong URL (phần tham số phía sau dấu ? trong URL)
Sau khi các liên kết phân trang đã được khởi tạo với các giá trị thích hợp, chúng ta cần hiển thị chúng trong trang chủ ở phía dưới danh sách các bài viết:
  • app/templates/index.html: Hiển thị các liên kết phân trang
...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    <a href="{{ prev_url }}">Newer posts</a>
    {% endif %}
    {% if next_url %}
    <a href="{{ next_url }}">Older posts</a>
    {% endif %}
    ...
Thay đổi trên sẽ hiển thị các liên kết phân trang ở phía dưới danh sách các bài viết trong trang chủ và trang Explore. Liên kết đầu tiên sẽ được hiển thị với tên gọi “Newer posts” và dùng để truy cập trang trước đó (vì chúng ta đã chọn cách sắp xếp các bài viết theo thứ tự từ mới đến cũ, các trang phía trước trang hiện hành sẽ có các bài viết mới hơn). Liên kết thứ hai được đặt tên là “Older posts” và dùng để truy cập trang sau của trang hiện hành. Nếu một trong hai liên kết này có giá trị là None, nó sẽ không được hiển thị.

Phân trang trong trang hồ sơ cá nhân

Chúng ta đã hoàn tất việc cập nhật trang chủ. Tuy nhiên, trang hồ sơ cá nhân cũng có một danh sách các bài viết của user đang đăng nhập. Vì vậy, chúng ta cũng cần thêm chức năng phân trang trong hồ sơ cá nhân để để bảo đảm tính nhất quán với trang chủ.
Trước hết, chúng ta cần cập nhật trang hồ sơ cá nhân để sử dụng các bài viết thật thay vì dùng danh sách các bài viết giả mà chúng ta đã dùng trước đây:
  • app/routes.py: Chức năng phân trang trong trang hồ sơ cá nhân
@app.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    page = request.args.get('page', 1, type=int)
    posts = user.posts.order_by(Post.timestamp.desc()).paginate(
        page, app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('user', username=user.username, page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('user', username=user.username, page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('user.html', user=user, posts=posts.items,
                           next_url=next_url, prev_url=prev_url)
Để lấy danh sách các bài viết từ user, chúng ta lợi dụng quan hệ user.posts đã được thiết lập sẵn bởi SQLAlchemy qua định nghĩa db.relationship() trong lớp User. Chúng ta sẽ sử dụng quan hệ này và thêm mệnh đề order_by() để sắp xếp các bài viết theo thức tự từ mới đến cũ, và sau đó tiến hành phân trang tương tự như trong trang chủ và trang Explore. Lưu ý rằng các liên kế phân trang tạo ra bởi hàm url_for() cần có thêm tham số username vì chúng sử dụng các URL của trang hồ sơ cá nhân và các URL này bao gồm cả username theo thiết kế của chúng ta trước đây.
Đến đây, chúng ta có thể cập nhật template user.html như trong trang chủ:
  • app/templates/user.html: Phân trang trong trang hồ sơ cá nhân
 ...
    {% for post in posts %}
        {% include '_post.html' %}
    {% endfor %}
    {% if prev_url %}
    <a href="{{ prev_url }}">Newer posts</a>
    {% endif %}
    {% if next_url %}
    <a href="{{ next_url }}">Older posts</a>
    {% endif %}
Bây giờ, bạn có thể chạy chương trình và thử nghiệm tính năng phân trang. Sau đó, bạn có thể điều chỉnh tham số POST_PER_PAGE cho thích hợp:
  • config.py: Số bài viết trong một trang
class Config(object):
    ...
    POSTS_PER_PAGE = 25

Getting Info...

Đăng nhận xét

Đồng ý cookie
Chúng tôi cung cấp cookie trên trang web này để phân tích lưu lượng truy cập, ghi nhớ các tùy chọn của bạn và tối ưu hóa trải nghiệm của bạn.
Oops!
Có vẻ như đã xảy ra sự cố với kết nối Internet của bạn. Vui lòng kết nối với internet và bắt đầu duyệt lại.
Trang web bị chặn
Xin lỗi! Trang web này không có sẵn ở quốc gia của bạn.