-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.html
717 lines (655 loc) · 22.2 KB
/
index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Django ORM Is All I Need... NOT!</title>
<meta name="description" content="A framework for easily creating beautiful presentations using HTML">
<meta name="author" content="Božidar Benko">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, minimal-ui">
<link rel="stylesheet" href="css/reveal.css">
<link rel="stylesheet" href="css/theme/black.css" id="theme">
<!-- Code syntax highlighting -->
<link rel="stylesheet" href="lib/css/zenburn.css">
<!-- Printing and PDF exports -->
<script>
var link = document.createElement( 'link' );
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = window.location.search.match( /print-pdf/gi ) ? 'css/print/pdf.css' : 'css/print/paper.css';
document.getElementsByTagName( 'head' )[0].appendChild( link );
</script>
<!--[if lt IE 9]>
<script src="lib/js/html5shiv.js"></script>
<![endif]-->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-67293895-1', 'auto');
ga('send', 'pageview');
</script>
</head>
<body>
<div class="reveal">
<!-- Any section element inside of this container is displayed as a slide -->
<div class="slides">
<section>
<section>
<h3>Django ORM Is All I Need...</h3>
<h1 class="fragment">NOT!</h1>
<p>
<small class="fragment">By Božidar Benko / <a href="http://twitter.com/bbenko">@bbenko</a> / CTO @VuMedi</small>
</p>
</section>
</section>
<section>
<h2>Django ORM is great</h2>
<p>
I love it and use it all the time.
</p>
<p>
I'm productive in it (more than in SQL).
</p>
</section>
<section>
<h2>2 (Wrong) Mantras</h2>
<p>
All CRUD should be done with stored procedures.
</p>
<small class="fragment">old school</small>
<p>
ORM is all I need.
</p>
<small class="fragment">site owners with no users</small>
</section>
<section>
<img src="img/one-does-not-simply-use-django-orm.jpg" />
</section>
<section>
<h2>Pareto 80-20</h2>
<ul>
<li>Use ORM whenever I can</li>
<li>Know it's limitations</li>
<li>Know which (SQL) code it produces</li>
<li>Know the database</li>
<li>Tweak the db</li>
<li>Write SQL</li>
</ul>
</section>
<section>
<h2>ORM Limitations</h2>
<ul>
<li>Unexpected results</li>
<li>Slow execution</li>
<li>Some things not supported</li>
</ul>
</section>
<section>
<section>
<h2>Unexpected results</h2>
<p>At VuMedi, we have users in various groups.</p>
<p>Most interesting for us are 2 surgeon groups: <ul><li>Surgeon</li><li>Flagged</small></li></ul></p>
<p>How many surgeons do we have?</p>
</section>
<section>
<h2>Easy</h2>
<pre><code data-trim contenteditable>User.objects.filter(groups__name__in=['Surgeon', 'Flagged']).count()
</code></pre>
</section>
<section>
<img src='img/one_meeelion_surgeons.jpg'/>
</section>
<section>
<h2>Too Much</h2>
<p>Hey BB, are you sure? That seems a bit too much.</p>
<p>- Off course I'm sure, but OK, to check, I'll write the same thing using Qs.</p>
<pre><code data-trim contenteditable>
User.objects.filter(Q(groups__name='Surgeon') | \
Q(groups__name='Flagged')).count()
</code></pre>
<p>One meeelion again!</p>
</section>
<section>
<img src='img/always_right.jpg'/>
</section>
<section>
<h2>Counting Twice?</h2>
<p>No, seriously, that's too much.</p>
<p>Some users are both 'Surgeon' and 'Flagged'.</p>
<p>Maybe you are counting those twice.</p>
</section>
<section>
<h2>Trust me, I'm an engineer</h2>
<p>Hey, I know Boolean algebra.</p>
<p>1 OR 1 == 1</p>
<p>Not 2!</p>
<p>But I'll write some tests.</p>
</section>
<section>
<h2>The Test</h2>
<pre><code>
house = User.objects.create_user(username='house',
email='[email protected]',
password='house123')
self.assertEqual(User.objects.count(), 1) # only House
# House is surgeon and flagged
house.groups.add(self.surgeon, self.flagged)
self.assertEqual(User.objects.filter(groups=self.surgeon).count(), 1)
self.assertEqual(User.objects.filter(groups=self.flagged).count(), 1)
# how many users that are surgeon or flagged?
# only one user in db, it must be one
surgeons = User.objects.filter(groups__in=[self.surgeon,
self.flagged])
self.assertEqual(surgeons.count(), 1)
</code></pre>
</section>
<section>
<h2>The Fail</h2>
<pre><code>
(doiainn)➜ doiainn git:(master) ✗ ./manage.py test
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_m2m_count (app.tests.AppTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/bb/code/doiainn/code/doiainn/app/tests.py", line 27, in test_m2m_count
self.assertEqual(surgeons.count(), 1)
AssertionError: 2 != 1
----------------------------------------------------------------------
Ran 1 test in 0.137s
FAILED (failures=1)
Destroying test database for alias 'default'... </code></pre>
</section>
<section>
<h2>Same with Qs</h2>
<pre><code>
surgeons = User.objects.filter(Q(groups=self.surgeon) |
Q(groups=self.flagged))
self.assertEqual(surgeons.count(), 2)
</code></pre>
</section>
<section>
<h2>SQL produced</h2>
<pre><code data-trim contenteditable>
SELECT "auth_user"."id",
"auth_user"."password",
"auth_user"."last_login",
"auth_user"."is_superuser",
"auth_user"."username",
...
FROM "auth_user"
INNER JOIN "auth_user_groups" ON
("auth_user"."id" = "auth_user_groups"."user_id")
WHERE ("auth_user_groups"."group_id" = 1
OR "auth_user_groups"."group_id" = 2);
1|pbkdf2_sha256$20000$SMZHa8Fn8nG1$cGdKi...
1|pbkdf2_sha256$20000$SMZHa8Fn8nG1$cGdKi...
</code></pre>
<p>... because ...</p>
<pre><code>
SELECT COUNT(*) FROM auth_user_groups WHERE user_id=1;
2
</code></pre>
</section>
<section>
<img src="img/sql-can-I-join-you.png" />
</section>
<section>
<h2>Distinct helps</h2>
<pre><code>
self.assertEqual(surgeons.distinct().count(), 1)
</code></pre>
<pre><code>
(doiainn)➜ doiainn git:(master) ✗ ./manage.py test
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.153s
OK
Destroying test database for alias 'default'...
</code></pre>
</section>
<section>
<img src="img/should_have.jpg" />
</section>
</section>
<section>
<section>
<h2>Slow Execution</h2>
<p>
Django Admin is great. You use it to build your MVP.
</p>
<p>Your table grows beyond 12M rows.</p>
<p>Change list view and search are now slow.</p>
</section>
<section>
<h2>Total Count</h2>
<p>Admin displays total number of records in change list view.</p>
<p>Having 12M+ rows, total count doesn't have to be exact.</p>
<p>When filtered, it should be exact.</p>
</section>
<section>
<h2>Postgres Has Count Approximation</h2>
<pre><code>
SELECT COUNT(*) FROM myapp_mymodel;
count
----------
12871557
(1 row)
Time: 47299.633 ms
SELECT reltuples FROM pg_class WHERE relname = 'myapp_mymodel';
reltuples
-------------
1.28186e+07
(1 row)
Time: 0.410 ms
</code></pre>
</section>
<section>
<h2>DjangoSnippets have the code</h2>
<p><a href="https://djangosnippets.org/snippets/2855/" target="_blank">Django Admin Speedup For Big Tables on Postgres</a></p>
<pre><code>
class LargeTableChangeList(ChangeList):
def get_results(self, request):
...
if not self.query_set.query.where:
full_result_count = result_count
else:
try:
cursor = connection.cursor()
cursor.execute("SELECT reltuples
FROM pg_class WHERE relname = %s",
[self.root_query_set.query.model._meta.db_table])
full_result_count = int(cursor.fetchone()[0])
except:
full_result_count = self.root_query_set.count()
...
</code></pre>
<p><small>Django 1.8 introduced <a href="https://docs.djangoproject.com/en/1.8/ref/contrib/admin/#django.contrib.admin.ModelAdmin.show_full_result_count" target="_blank">ModelAdmin.show_full_result_count</a> to disable count.</small></p>
</section>
<section>
<h2>But search is still slow</h2>
<img src="img/not-thrilling.gif" />
</section>
<section>
<h2>MDN field</h2>
<p>
It was a traffic usage table with MDN field.
</p>
<p>MDN is 10 digit number, but can have leading zeros, so I used CharField.</p>
<pre><code>
mdn = models.CharField(...)
</code></pre>
</section>
<section>
<h2>Admin default search</h2>
<pre><code>
class MyModel(admin.ModelAdmin):
search_fields = ['mdn', ]
</code></pre>
<p>does icontains - case insensitive contains</p>
</section>
<section>
<h2>I don't need contains</h2>
<pre><code>
class MyModel(admin.ModelAdmin):
search_fields = ['=mdn', ]
</code></pre>
<p>does iexact - case insensitive exact</p>
</section>
<section>
<h2>MDN Is Digits Only</h2>
<p>I don't need case sensitivity.</p>
<p>But, at the time, you could not further customize the search.</p>
<pre><code>
# Apply keyword searches.
def construct_search(field_name):
...
elif field_name.startswith('='):
return "%s__iexact" % field_name[1:]
</code></pre>
<p>From Django 1.6, you can use <a href="https://docs.djangoproject.com/en/1.8/ref/contrib/admin/#django.contrib.admin.ModelAdmin.get_search_results" target="_blank">ModelAdmin.get_search_results</a>.</p>
</section>
<section>
<h2>SQL DEBUGGING</h2>
<pre><code>
SELECT "myapp_mymodel"."id", "myapp_mymodel"."mdn”, ...
FROM "myapp_mymodel"
WHERE UPPER("myapp_mymodel"."mdn"::text) = UPPER(‘1234567890’)
LIMIT 100;
</code></pre>
<p>
UPPER does case insensitivity.
</p>
<p>
It took ~7.5 seconds to execute this query.
</p>
<p>Without UPPER, it took ~6 milliseconds (there was a btree index on a field).</p>
</section>
<section>
<h2>Index On Expressions</h2>
<p>Can't remove UPPER.</p>
<p>But you can do indexes on functions in Postgres.</p>
<p>Even better, you can do <a href="http://www.postgresql.org/docs/9.3/static/indexes-expressional.html" target="_blank">index on any expression</a>.</p>
</section>
<section>
<h2>Postgres Rocks</h2>
<p>I can create an index on UPPER(mdn)</p>
<pre><code>
CREATE INDEX myapp_mymodel_mdn_upper ON myapp_mymodel(UPPER(mdn));
CREATE INDEX
Time: 377055.345 ms
</code></pre>
<p>The whole admin view with search results now comes back in under 2 seconds!</p>
</section>
<section>
<img src="img/more-fun.jpg" />
</section>
</section>
<section>
<section>
<h2>Slow Execution II</h2>
<p>@VuMedi, our impression table is big.</p>
<pre><code>
=> SELECT COUNT(*) FROM tracking_impression;
count
-----------
244116752
(1 row)
</code></pre>
<p>System and customer analytics (mostly) need only the last month of that data.</p>
<p>Quering this table is pretty slow.</p>
</section>
<section>
<h2>Partitioning To The Rescue</h2>
<p>Ordinary indexes don't help.</p>
<p>But, I can partition the data by months.</p>
</section>
<section>
<h2>Postgres Partitioning</h2>
<p>Splitting one large table into smaller physical pieces.</p>
<ul>
<li>improved query performance</li>
<li>reduced index size for the heavily used parts (fit in RAM)</li>
<li>faster bulk loads and deletes</li>
<li>seldom-used data can be migrated to cheap storage</li>
</ul>
</section>
<section>
<h2>Partitioning Implementation</h2>
<ul>
<li>via table inheritence</li>
<li>one master table</li>
<li>each partition is created as child of the master table</li>
<li>master table is empty</li>
<li>table constraints can be added to define allowed keys in each partition</li>
<li>index on key colums(s) can be created in each child</li>
<li>insert trigger to redirect data from master to appropriate partition</li>
</ul>
</section>
<section>
<h2>Partitioning By Months</h2>
<p>Master table / Django Model</p>
<pre><code>
class Impression(models.Model):
user = models.ForeignKey(User)
when = models.DateTimeField(auto_now_add=True)
content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField()
content_object = generic.GenericForeignKey()
...
</code></pre>
<pre><code>
CREATE TABLE "tracking_impression" (
"id" serial NOT NULL PRIMARY KEY,
"user_id" integer NOT NULL REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED,
"when" timestamp with time zone NOT NULL,
"content_type_id" integer NOT NULL
REFERENCES "django_content_type" ("id")
DEFERRABLE INITIALLY DEFERRED,
"object_id" integer CHECK ("object_id" >= 0) NOT NULL,
...
);
</code></pre>
</section>
<section>
<h2>Create Partition For Each Month</h2>
<pre><code>
CREATE TABLE tracking_impression_2015_09 (
CHECK ( "when" >= '2015-09-01' AND "when" < '2015-10-01' )
) INHERITS (tracking_impression);
CREATE INDEX ON tracking_impression_2015_09 USING btree(user_id);
CREATE INDEX ON tracking_impression_2015_09 USING btree("when");
</code></pre>
</section>
<section>
<h2>Insert Trigger</h2>
<p>We want to say:</p>
<pre><code>
INSERT INTO tracking_impression ...
</code></pre>
<p>and have the data be redirected into the appropriate partition table.</p>
<pre><code>
CREATE OR REPLACE FUNCTION tracking_impression_insert_trigger()
RETURNS TRIGGER AS $$
BEGIN
IF ( NEW."when" >= '2015-09-01' AND
NEW."when" < '2015-10-01' ) THEN
INSERT INTO tracking_impression_2015_09 VALUES (NEW.*);
ELSIF ( NEW."when" >= '2015-08-01' AND
NEW."when" < '2015-09-01' ) THEN
INSERT INTO tracking_impression_2015_08 VALUES (NEW.*);
...
ELSE
RAISE EXCEPTION 'Date out of range.';
END IF;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;
</code></pre>
</section>
<section>
<img src="img/repeat-myself.jpg" />
</section>
<section>
<h2>Smart Partitioning Trigger</h2>
<pre><code>
CREATE OR REPLACE FUNCTION trg_tracking_impression_partition()
RETURNS trigger AS $func$
DECLARE
_tablename text := 'tracking_impression_' ||
to_char(NEW."when", 'YYYY_MM');
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname = _tablename
) THEN ...
</code></pre>
</section>
<section>
<pre><code>
) THEN ...
EXECUTE format('CREATE TABLE %I
(CHECK ("when" >= %L
AND "when" < %L))
INHERITS (public.tracking_impression)'
, _tablename
, to_char(NEW."when", 'YYYY-MM-01')
, to_char((date_trunc('MONTH', NEW."when")
+ INTERVAL '1 MONTH')::date, 'YYYY-MM-DD')
);
EXECUTE format('CREATE INDEX ON %I USING btree(user_id)',
_tablename);
EXECUTE format('CREATE INDEX ON %I USING btree("when")',
_tablename);
END IF; ...
</code></pre>
</section>
<section>
<pre><code>
...
EXECUTE 'INSERT INTO ' || quote_ident(_tablename)
|| ' VALUES ($1.*) RETURNING id' USING NEW;
RETURN NULL;
END
$func$ LANGUAGE plpgsql SET search_path = public;
-- trigger
CREATE TRIGGER tracking_impression_insert_trigger
BEFORE INSERT ON tracking_impression
FOR EACH ROW EXECUTE PROCEDURE trg_tracking_impression_partition();
</code></pre>
</section>
<section>
<h2>The Data And The Speed</h2>
<pre><code>
INSERT INTO
tracking_impression(user_id, "when", content_type_id, object_id, ..)
SELECT user_id, "when", content_type_id, object_id, ..
FROM tracking_impression_np;
</code></pre>
<pre><code>
-- old non partitioned table
=> SELECT "tracking_impression".*
FROM "tracking_impression_np"
WHERE ("tracking_impression_np"."when"
BETWEEN '2014-09-05 00:00:00-07:00'
AND '2014-10-04 00:00:00-07:00' AND ...;
Time: 2983993.619 ms
-- new partitioned table
=> SELECT "tracking_impression".*
FROM "tracking_impression"
WHERE ("tracking_impression"."when"
BETWEEN '2014-09-05 00:00:00-07:00'
AND '2014-10-04 00:00:00-07:00' AND ...;
Time: 263.702 ms
</code></pre>
</section>
<section>
<h2>Django Compatibility</h2>
<p>Almost (everything) works out of the box.</p>
<pre><code>
return query.get_compiler(using=using).execute_sql(return_id)
.../django/db/models/sql/compiler.py", line 914, in execute_sql
return self.connection.ops.fetch_returned_insert_id(cursor)
.../django/db/backends/__init__.py", line 529,
in fetch_returned_insert_id
return cursor.fetchone()[0]
TypeError: 'NoneType' object is unsubscriptable
</code></pre>
<pre><code>
class Impression(models.Model):
...
def save(self, *args, **kwargs):
from django.db import connection
connection.features.can_return_id_from_insert = False
super(Impression, self).save(*args, **kwargs)
connection.features.can_return_id_from_insert = True
</code></pre>
</section>
<section>
<h2>That's My Man, Postgres</h2>
<img src="img/tysonreaction.gif">
</section>
</section>
<section>
<section>
<h2>Some Things Not Supported</h2>
<p>Hey BB, there are duplicates when I sort in model's admin</p>
<p>by due_date field from not closed event.</p>
<img src="img/be-great.jpg" />
</section>
<section>
<h2>Duplicates again</h2>
<pre><code>
class Model(models.Model):
...
class ModelEvent(models.Model):
model = models.ForeignKey(Model)
due_date = models.DateField(...)
closed = models.BooleanField(default=False)
</code></pre>
<p>This is how admin prepares queryset:</p>
<pre><code>
In [7]: Model.objects.all()\
.order_by('modelevent__due_date').distinct()
Out[7]: [<Model: A>, <Model: B>, <Model: B>, <Model: C>, ...
</code></pre>
<p>2 events for model B --> model B twice in queryset.</p>
</section>
<section>
<h2>Not even distinct helps</h2>
<pre><code>
SELECT DISTINCT "model"."id",
...,
"modelevent"."due_date"
FROM "model"
LEFT OUTER JOIN "modelevent" ON ("model"."id" = "modelevent"."model_id")
ORDER BY "modelevent"."due_date" ASC
</code></pre>
</section>
<section>
<h2>Take Only The First Event</h2>
<p>Annotate on min due_date.</p>
<pre><code>
In [7]: Model.objects.all()\
.annotate(Min('modelevent__due_date'))\
.order_by('modelevent__due_date__min')\
.distinct()
Out[7]: [<Model: A>, <Model: B>, <Model: C>, ...
</code></pre>
<p>That solved the duplicates.</p>
<p>But we need to use only closed=False in event.</p>
</section>
<section>
<h2>No Filter In Annotate</h2>
<p>Write SQL. Use extra.</p>
<pre><code>
Model.objects.all().extra(select={'modelevent__due_date__min':
"""SELECT MIN(due_date) FROM app_model
WHERE model_id=app_model.id
AND closed=false"""})
</code></pre>
</section>
<section>
<img src="img/enough.jpg" />
</section>
</section>
<section>
<h1>Questions?</h1>
<br><br><br><br><br><br>
<small>VuMedi is hiring.</small>
</section>
</div>
</div>
<script src="lib/js/head.min.js"></script>
<script src="js/reveal.js"></script>
<script>
// Full list of configuration options available at:
// https://github.com/hakimel/reveal.js#configuration
Reveal.initialize({
controls: true,
progress: true,
history: true,
center: true,
transition: 'slide', // none/fade/slide/convex/concave/zoom
// Optional reveal.js plugins
dependencies: [
{ src: 'lib/js/classList.js', condition: function() { return !document.body.classList; } },
{ src: 'plugin/markdown/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
{ src: 'plugin/markdown/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
{ src: 'plugin/highlight/highlight.js', async: true, condition: function() { return !!document.querySelector( 'pre code' ); }, callback: function() { hljs.initHighlightingOnLoad(); } },
{ src: 'plugin/zoom-js/zoom.js', async: true },
{ src: 'plugin/notes/notes.js', async: true }
]
});
</script>
</body>
</html>