Skip to content

Commit 779cd9b

Browse files
authored
Performance helpers
Issue #337
2 parents 1659d8c + 7343def commit 779cd9b

16 files changed

+235
-18
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ pip-delete-this-directory.txt
4040
# VirtualEnv
4141
.venv/
4242

43+
# Developers
4344
*.sw*
45+
manage.py
46+
.DS_Store

.travis.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ script:
4949
- isort --check-only --verbose --recursive --diff rest_framework_json_api
5050
# example has extra dependencies that are installed in a dev environment
5151
# but are not installed in CI. Explicitly set those packages.
52-
- isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy example
52+
- isort --check-only --verbose --recursive --diff --thirdparty pytest --thirdparty polymorphic --thirdparty pytest_factoryboy --thirdparty packaging example
5353
- coverage run setup.py -v test
5454
after_success:
5555
- codecov

docs/usage.md

+49-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ REST_FRAMEWORK = {
2323
),
2424
'DEFAULT_RENDERER_CLASSES': (
2525
'rest_framework_json_api.renderers.JSONRenderer',
26-
'rest_framework.renderers.BrowsableAPIRenderer',
26+
# If you're performance testing, you will want to use the browseable API
27+
# without forms, as the forms can generate their own queries.
28+
# If performance testing, enable:
29+
# 'example.utils.BrowsableAPIRendererWithoutForms',
30+
# Otherwise, to play around with the browseable API, enable:
31+
'rest_framework.renderers.BrowsableAPIRenderer'
2732
),
2833
'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',
2934
}
@@ -36,6 +41,12 @@ retrieve the page can be customized by subclassing `PageNumberPagination` and
3641
overriding the `page_query_param`. Page size can be controlled per request via
3742
the `PAGINATE_BY_PARAM` query parameter (`page_size` by default).
3843

44+
#### Performance Testing
45+
46+
If you are trying to see if your viewsets are configured properly to optimize performance,
47+
it is preferable to use `example.utils.BrowsableAPIRendererWithoutForms` instead of the default `BrowsableAPIRenderer`
48+
to remove queries introduced by the forms themselves.
49+
3950
### Serializers
4051

4152
It is recommended to import the base serializer classes from this package
@@ -558,6 +569,43 @@ class QuestSerializer(serializers.ModelSerializer):
558569
`included_resources` informs DJA of **what** you would like to include.
559570
`included_serializers` tells DJA **how** you want to include it.
560571

572+
#### Performance improvements
573+
574+
Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m*(n+1) queries.
575+
576+
A viewset helper was designed to allow for greater flexibility and it is automatically available when subclassing
577+
`views.ModelViewSet`
578+
```
579+
# When MyViewSet is called with ?include=author it will dynamically prefetch author and author.bio
580+
class MyViewSet(viewsets.ModelViewSet):
581+
queryset = Book.objects.all()
582+
prefetch_for_includes = {
583+
'__all__': [],
584+
'author': ['author', 'author__bio']
585+
'category.section': ['category']
586+
}
587+
```
588+
589+
The special keyword `__all__` can be used to specify a prefetch which should be done regardless of the include, similar to making the prefetch yourself on the QuerySet.
590+
591+
Using the helper to prefetch, rather than attempting to minimise queries via select_related might give you better performance depending on the characteristics of your data and database.
592+
593+
For example:
594+
595+
If you have a single model, e.g. Book, which has four relations e.g. Author, Publisher, CopyrightHolder, Category.
596+
597+
To display 25 books and related models, you would need to either do:
598+
599+
a) 1 query via selected_related, e.g. SELECT * FROM books LEFT JOIN author LEFT JOIN publisher LEFT JOIN CopyrightHolder LEFT JOIN Category
600+
601+
b) 4 small queries via prefetch_related.
602+
603+
If you have 1M books, 50k authors, 10k categories, 10k copyrightholders
604+
in the select_related scenario, you've just created a in-memory table
605+
with 1e18 rows which will likely exhaust any available memory and
606+
slow your database to crawl.
607+
608+
The prefetch_related case will issue 4 queries, but they will be small and fast queries.
561609
<!--
562610
### Relationships
563611
### Errors

example/factories/__init__.py renamed to example/factories.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22

33
import factory
44
from faker import Factory as FakerFactory
5+
56
from example.models import (
6-
Blog, Author, AuthorBio, Entry, Comment, TaggedItem, ArtProject, ResearchProject, Company
7+
ArtProject,
8+
Author,
9+
AuthorBio,
10+
Blog,
11+
Comment,
12+
Company,
13+
Entry,
14+
ResearchProject,
15+
TaggedItem
716
)
817

918
faker = FakerFactory.create()
@@ -64,7 +73,6 @@ class Meta:
6473

6574

6675
class TaggedItemFactory(factory.django.DjangoModelFactory):
67-
6876
class Meta:
6977
model = TaggedItem
7078

example/models.py

+18
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class TaggedItem(BaseModel):
2828
def __str__(self):
2929
return self.tag
3030

31+
class Meta:
32+
ordering = ('id',)
33+
3134

3235
@python_2_unicode_compatible
3336
class Blog(BaseModel):
@@ -38,6 +41,9 @@ class Blog(BaseModel):
3841
def __str__(self):
3942
return self.name
4043

44+
class Meta:
45+
ordering = ('id',)
46+
4147

4248
@python_2_unicode_compatible
4349
class Author(BaseModel):
@@ -47,6 +53,9 @@ class Author(BaseModel):
4753
def __str__(self):
4854
return self.name
4955

56+
class Meta:
57+
ordering = ('id',)
58+
5059

5160
@python_2_unicode_compatible
5261
class AuthorBio(BaseModel):
@@ -56,6 +65,9 @@ class AuthorBio(BaseModel):
5665
def __str__(self):
5766
return self.author.name
5867

68+
class Meta:
69+
ordering = ('id',)
70+
5971

6072
@python_2_unicode_compatible
6173
class Entry(BaseModel):
@@ -73,6 +85,9 @@ class Entry(BaseModel):
7385
def __str__(self):
7486
return self.headline
7587

88+
class Meta:
89+
ordering = ('id',)
90+
7691

7792
@python_2_unicode_compatible
7893
class Comment(BaseModel):
@@ -87,6 +102,9 @@ class Comment(BaseModel):
87102
def __str__(self):
88103
return self.body
89104

105+
class Meta:
106+
ordering = ('id',)
107+
90108

91109
class Project(PolymorphicModel):
92110
topic = models.CharField(max_length=30)

example/serializers.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from datetime import datetime
22

33
import rest_framework
4-
54
from packaging import version
5+
66
from rest_framework_json_api import relations, serializers
77

88
from example.models import (
@@ -20,14 +20,12 @@
2020

2121

2222
class TaggedItemSerializer(serializers.ModelSerializer):
23-
2423
class Meta:
2524
model = TaggedItem
26-
fields = ('tag', )
25+
fields = ('tag',)
2726

2827

2928
class BlogSerializer(serializers.ModelSerializer):
30-
3129
copyright = serializers.SerializerMethodField()
3230
tags = TaggedItemSerializer(many=True, read_only=True)
3331

@@ -46,12 +44,11 @@ def get_root_meta(self, resource, many):
4644
class Meta:
4745
model = Blog
4846
fields = ('name', 'url', 'tags')
49-
read_only_fields = ('tags', )
47+
read_only_fields = ('tags',)
5048
meta_fields = ('copyright',)
5149

5250

5351
class EntrySerializer(serializers.ModelSerializer):
54-
5552
def __init__(self, *args, **kwargs):
5653
super(EntrySerializer, self).__init__(*args, **kwargs)
5754
# to make testing more concise we'll only output the
@@ -97,15 +94,14 @@ class Meta:
9794
model = Entry
9895
fields = ('blog', 'headline', 'body_text', 'pub_date', 'mod_date',
9996
'authors', 'comments', 'featured', 'suggested', 'tags')
100-
read_only_fields = ('tags', )
97+
read_only_fields = ('tags',)
10198
meta_fields = ('body_format',)
10299

103100
class JSONAPIMeta:
104101
included_resources = ['comments']
105102

106103

107104
class AuthorBioSerializer(serializers.ModelSerializer):
108-
109105
class Meta:
110106
model = AuthorBio
111107
fields = ('author', 'body')

example/settings/dev.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
'rest_framework',
2626
'polymorphic',
2727
'example',
28+
'debug_toolbar',
2829
]
2930

3031
TEMPLATES = [
@@ -58,7 +59,11 @@
5859

5960
PASSWORD_HASHERS = ('django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', )
6061

61-
MIDDLEWARE_CLASSES = ()
62+
MIDDLEWARE_CLASSES = (
63+
'debug_toolbar.middleware.DebugToolbarMiddleware',
64+
)
65+
66+
INTERNAL_IPS = ('127.0.0.1', )
6267

6368
JSON_API_FORMAT_KEYS = 'camelize'
6469
JSON_API_FORMAT_TYPES = 'camelize'
@@ -74,6 +79,12 @@
7479
),
7580
'DEFAULT_RENDERER_CLASSES': (
7681
'rest_framework_json_api.renderers.JSONRenderer',
82+
83+
# If you're performance testing, you will want to use the browseable API
84+
# without forms, as the forms can generate their own queries.
85+
# If performance testing, enable:
86+
# 'example.utils.BrowsableAPIRendererWithoutForms',
87+
# Otherwise, to play around with the browseable API, enable:
7788
'rest_framework.renderers.BrowsableAPIRenderer',
7889
),
7990
'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',

example/tests/test_performance.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from django.utils import timezone
2+
from rest_framework.test import APITestCase
3+
4+
from example.factories import CommentFactory
5+
from example.models import Author, Blog, Comment, Entry
6+
7+
8+
class PerformanceTestCase(APITestCase):
9+
def setUp(self):
10+
self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com')
11+
self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog")
12+
self.other_blog = Blog.objects.create(name='Other blog', tagline="It's another blog")
13+
self.first_entry = Entry.objects.create(
14+
blog=self.blog,
15+
headline='headline one',
16+
body_text='body_text two',
17+
pub_date=timezone.now(),
18+
mod_date=timezone.now(),
19+
n_comments=0,
20+
n_pingbacks=0,
21+
rating=3
22+
)
23+
self.second_entry = Entry.objects.create(
24+
blog=self.blog,
25+
headline='headline two',
26+
body_text='body_text one',
27+
pub_date=timezone.now(),
28+
mod_date=timezone.now(),
29+
n_comments=0,
30+
n_pingbacks=0,
31+
rating=1
32+
)
33+
self.comment = Comment.objects.create(entry=self.first_entry)
34+
CommentFactory.create_batch(50)
35+
36+
def test_query_count_no_includes(self):
37+
""" We expect a simple list view to issue only two queries.
38+
39+
1. The number of results in the set (e.g. a COUNT query),
40+
only necessary because we're using PageNumberPagination
41+
2. The SELECT query for the set
42+
"""
43+
with self.assertNumQueries(2):
44+
response = self.client.get('/comments?page_size=25')
45+
self.assertEqual(len(response.data['results']), 25)
46+
47+
def test_query_count_include_author(self):
48+
""" We expect a list view with an include have three queries:
49+
50+
1. Primary resource COUNT query
51+
2. Primary resource SELECT
52+
3. Authors prefetched
53+
3. Entries prefetched
54+
"""
55+
with self.assertNumQueries(4):
56+
response = self.client.get('/comments?include=author&page_size=25')
57+
self.assertEqual(len(response.data['results']), 25)

example/urls.py

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.conf import settings
12
from django.conf.urls import include, url
23
from rest_framework import routers
34

@@ -22,3 +23,10 @@
2223
urlpatterns = [
2324
url(r'^', include(router.urls)),
2425
]
26+
27+
28+
if settings.DEBUG:
29+
import debug_toolbar
30+
urlpatterns = [
31+
url(r'^__debug__/', include(debug_toolbar.urls)),
32+
] + urlpatterns

example/utils.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from rest_framework.renderers import BrowsableAPIRenderer
2+
3+
4+
class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer):
5+
"""Renders the browsable api, but excludes the forms."""
6+
7+
def get_context(self, *args, **kwargs):
8+
ctx = super().get_context(*args, **kwargs)
9+
ctx['display_edit_forms'] = False
10+
return ctx
11+
12+
def show_form_for_method(self, view, method, request, obj):
13+
"""We never want to do this! So just return False."""
14+
return False
15+
16+
def get_rendered_html_form(self, data, view, method, request):
17+
"""Why render _any_ forms at all. This method should return
18+
rendered HTML, so let's simply return an empty string.
19+
"""
20+
return ""

example/views.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ class AuthorViewSet(ModelViewSet):
7474

7575

7676
class CommentViewSet(ModelViewSet):
77-
queryset = Comment.objects.all()
77+
queryset = Comment.objects.select_related('author', 'entry')
7878
serializer_class = CommentSerializer
79+
prefetch_for_includes = {
80+
'__all__': [],
81+
'author': ['author', 'author__bio', 'author__entries'],
82+
'entry': ['author', 'author__bio', 'author__entries']
83+
}
7984

8085

8186
class CompanyViewset(ModelViewSet):

requirements-development.txt

+3
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ recommonmark
1010
Sphinx
1111
sphinx_rtd_theme
1212
tox
13+
mock
14+
django-debug-toolbar
15+
packaging==16.8

0 commit comments

Comments
 (0)