]> Dogcows Code - chaz/talk-bring-your-own-user-agent/commitdiff
initial commit
authorCharles McGarvey <cmcgarvey@bluehost.com>
Wed, 20 Jun 2018 10:16:17 +0000 (04:16 -0600)
committerCharles McGarvey <cmcgarvey@bluehost.com>
Wed, 20 Jun 2018 10:43:07 +0000 (04:43 -0600)
18 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
css/common.css [new file with mode: 0644]
css/slides.css [new file with mode: 0644]
img/deps1.png [new file with mode: 0644]
img/deps2.png [new file with mode: 0644]
img/deps3.png [new file with mode: 0644]
img/deps4.png [new file with mode: 0644]
img/deps5.png [new file with mode: 0644]
img/http-anyua-diagram.dot [new file with mode: 0644]
img/http-anyua-middleware-diagram.dot [new file with mode: 0644]
img/opg.png [new file with mode: 0644]
img/twilio.png [new file with mode: 0644]
img/webservice-on-cpan-circled.png [new file with mode: 0644]
img/webservice-on-cpan.png [new file with mode: 0644]
js/common.js [new file with mode: 0644]
slides.html [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..c8fe356
--- /dev/null
@@ -0,0 +1,5 @@
+*.pdf
+/img/http-anyua-diagram.svg
+/img/http-anyua-middleware-diagram.svg
+/remark.min.js
+/slides-offline.html
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..1f082a7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,38 @@
+
+SLIDES  = bring-your-own-user-agent
+BROWSER = google-chrome
+DOT     = dot
+
+dotfiles = $(shell find . -iname '*.dot')
+svgfiles = $(patsubst %.dot,%.svg,$(dotfiles))
+
+all: offline
+
+clean:
+       rm -f slides-offline.html remark.min.js $(SLIDES).pdf $(svgfiles)
+
+offline: slides-offline.html remark.min.js $(svgfiles)
+
+pdf: $(SLIDES).pdf
+
+run: $(svgfiles)
+       $(BROWSER) slides.html
+
+run-offline: offline
+       $(BROWSER) slides-offline.html
+
+%.svg: %.dot
+       $(DOT) -Tsvg -o$@ $<
+
+$(SLIDES).pdf: slides.html $(wildcard css/*) $(wildcard img/*) $(svgfiles)
+       docker run --network host --rm -t -v `pwd`:/slides astefanutti/decktape http://localhost:5000 /slides/$(SLIDES).pdf
+
+slides-offline.html: slides.html
+       sed -e '1 a <!-- This file is auto-generated - DO NOT EDIT!!! -->' \
+           -e 's!https://.*remark-latest\.min\.js!remark.min.js!' <$< >$@
+
+remark.min.js:
+       curl -Lo $@ https://gnab.github.io/remark/downloads/remark-latest.min.js
+
+.PHONY: all clean offline pdf run run-offline
+
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..30edec8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,18 @@
+
+# Slides for "Bring Your Own User-Agent"
+
+This directory contains the slides for my talk entitled "Bring Your Own User-Agent".
+
+The slides were written in [Markdown](https://daringfireball.net/projects/markdown/) format and can be rendered with
+[remark](http://remarkjs.com/).
+
+## Options
+
+To view the slides:
+
+    make run
+
+To build a PDF version of the slides:
+
+    make pdf
+
diff --git a/css/common.css b/css/common.css
new file mode 100644 (file)
index 0000000..8689442
--- /dev/null
@@ -0,0 +1,107 @@
+@import url(https://fonts.googleapis.com/css?family=Open+Sans);
+@import url(https://fonts.googleapis.com/css?family=Muli);
+@import url(https://fonts.googleapis.com/css?family=Inconsolata);
+
+.remark-slide-content {
+    font-family: 'Open Sans';
+    font-size: 22px;
+}
+
+.remark-slide-content h1,
+.remark-slide-content h2,
+.remark-slide-content h3 {
+    font-family: Muli;
+    font-weight: bold;
+}
+.remark-slide-content h2 {
+    font-size: 40px;
+}
+.remark-slide-content h3 {
+    font-size: 30px;
+}
+
+.remark-slide-content li {
+    line-height: 2em;
+}
+.remark-slide-content .condensed li {
+    line-height: 1em;
+}
+
+.remark-code, .remark-inline-code {
+    font-family: Inconsolata, monospace;
+}
+.remark-code {
+    border-radius: 5px;
+    border: 1px solid #ccc;
+}
+
+.remark-slide-content blockquote {
+    border-radius: 5px;
+    border: 1px solid #ccc;
+    background: #f0f0f0;
+    margin: 1.5em 0;
+    padding: 1em;
+    quotes: "\201C""\201D""\2018""\2019";
+    position: relative;
+}
+.remark-slide-content blockquote:before {
+    color: #ccc;
+    position: absolute;
+    content: open-quote;
+    font-size: 6em;
+    line-height: 0.1em;
+    margin-right: 0.25em;
+    vertical-align: -0.4em;
+}
+
+.remark-slide-content .col {
+    box-sizing: border-box;
+    display: block;
+    float: left;
+    border: 0;
+    margin: 0;
+    padding: 0;
+    width: 50%;
+}
+.remark-slide-content .col:nth-of-type(odd) {
+    padding: 0 1em 0 0;
+}
+.remark-slide-content .col:nth-of-type(even) {
+    padding: 0 0 0 1em;
+}
+.remark-slide-content .col.sep:nth-of-type(odd) {
+    border-right: 1px solid #ccc;
+}
+.remark-slide-content .col.sep:nth-of-type(even) {
+    border-left: 1px solid #ccc;
+}
+
+/* fix non-collapsing margin */
+.remark-slide-content .marginfix h3,
+.remark-slide-content .marginfix ul {
+    margin-top: 0;
+}
+
+.remark-slide-content .top-right {
+    float: right;
+    margin: 50px 0 0 2em;
+}
+
+.remark-slide-content .highlight {
+    color: #7d9726;
+}
+
+.big {
+    font-size: 150%;
+}
+.small {
+    font-size: 50%;
+}
+
+.remark-code-line-highlighted {
+    background-color: rgba(200,200,255,.25);
+}
+.hljs-hybrid .hljs {
+    background: rgba(29,31,33,.96);
+}
+
diff --git a/css/slides.css b/css/slides.css
new file mode 100644 (file)
index 0000000..b4e4513
--- /dev/null
@@ -0,0 +1,9 @@
+
+.img-map img {
+    width: 75%;
+}
+
+.ex-code .perl {
+    font-size: 38px;
+}
+
diff --git a/img/deps1.png b/img/deps1.png
new file mode 100644 (file)
index 0000000..d285c57
Binary files /dev/null and b/img/deps1.png differ
diff --git a/img/deps2.png b/img/deps2.png
new file mode 100644 (file)
index 0000000..0c1c1a6
Binary files /dev/null and b/img/deps2.png differ
diff --git a/img/deps3.png b/img/deps3.png
new file mode 100644 (file)
index 0000000..6642d57
Binary files /dev/null and b/img/deps3.png differ
diff --git a/img/deps4.png b/img/deps4.png
new file mode 100644 (file)
index 0000000..8e76c48
Binary files /dev/null and b/img/deps4.png differ
diff --git a/img/deps5.png b/img/deps5.png
new file mode 100644 (file)
index 0000000..e81a85d
Binary files /dev/null and b/img/deps5.png differ
diff --git a/img/http-anyua-diagram.dot b/img/http-anyua-diagram.dot
new file mode 100644 (file)
index 0000000..8917205
--- /dev/null
@@ -0,0 +1,28 @@
+
+digraph G {
+    node
+    [
+        fontname    = "Inconsolata"
+        fontsize    = 20
+        shape       = record
+        style       = rounded
+        margin      = "0.2,0.2"
+    ]
+
+    edge
+    [
+        fontname    = "Inconsolata"
+        fontsize    = 18
+        arrowhead   = vee
+        arrowtail   = vee
+        arrowsize   = 2
+    ]
+
+    "WebService module" -> "HTTP::AnyUA->request"
+    "HTTP::AnyUA->request" -> "Real user agent,\ne.g. HTTP::Tiny" [label="   Transform request and response  "]
+    "Real user agent,\ne.g. HTTP::Tiny" -> "internet"
+    "internet" -> "Real user agent,\ne.g. HTTP::Tiny"
+    "Real user agent,\ne.g. HTTP::Tiny" -> "HTTP::AnyUA->request"
+    "HTTP::AnyUA->request" -> "WebService module"
+}
+
diff --git a/img/http-anyua-middleware-diagram.dot b/img/http-anyua-middleware-diagram.dot
new file mode 100644 (file)
index 0000000..633d85e
--- /dev/null
@@ -0,0 +1,32 @@
+
+digraph G {
+    node
+    [
+        fontname    = "Inconsolata"
+        fontsize    = 20
+        shape       = record
+        style       = rounded
+        margin      = "0.2,0.2"
+    ]
+
+    edge
+    [
+        fontname    = "Inconsolata"
+        fontsize    = 18
+        arrowhead   = vee
+        arrowtail   = vee
+        arrowsize   = 2
+    ]
+
+    "WebService module" -> "HTTP::AnyUA->request"
+    "HTTP::AnyUA->request" -> "Middleware"
+    "Middleware" -> "Real user agent,\ne.g. HTTP::Tiny" [label="   Transform request and response  "]
+    "Real user agent,\ne.g. HTTP::Tiny" -> "internet"
+    "internet" -> "Real user agent,\ne.g. HTTP::Tiny"
+    "Real user agent,\ne.g. HTTP::Tiny" -> "Middleware"
+    "Middleware" -> "HTTP::AnyUA->request"
+    "HTTP::AnyUA->request" -> "WebService module"
+
+    "Middleware" [style="rounded,filled",fillcolor="#88FF88"]
+}
+
diff --git a/img/opg.png b/img/opg.png
new file mode 100644 (file)
index 0000000..d85d2ca
Binary files /dev/null and b/img/opg.png differ
diff --git a/img/twilio.png b/img/twilio.png
new file mode 100644 (file)
index 0000000..19dc341
Binary files /dev/null and b/img/twilio.png differ
diff --git a/img/webservice-on-cpan-circled.png b/img/webservice-on-cpan-circled.png
new file mode 100644 (file)
index 0000000..faedb72
Binary files /dev/null and b/img/webservice-on-cpan-circled.png differ
diff --git a/img/webservice-on-cpan.png b/img/webservice-on-cpan.png
new file mode 100644 (file)
index 0000000..50e9b67
Binary files /dev/null and b/img/webservice-on-cpan.png differ
diff --git a/js/common.js b/js/common.js
new file mode 100644 (file)
index 0000000..3832adb
--- /dev/null
@@ -0,0 +1,55 @@
+
+/*
+ * Create a shortcut on a number key to jump to a named slide.
+ *
+ * Using the hotkey on the target slide will go "back" to the slide that was active before jumping
+ * to the target slide.
+ *
+ * Example: Jump to the slide named "myslide" when the "1" key is pressed.
+ *   createHotkey(1, 'myslide');
+ */
+function createHotkey(key, slideName) {
+    var targetSlide = slideshow.getSlideByName(slideName);
+    var lastSlide   = -1;
+    document.addEventListener('keydown', function(e) {
+        if (e.which === key + 48) {
+            var currentNum  = slideshow.getCurrentSlideIndex() + 1;
+            var targetNum   = targetSlide.getSlideIndex() + 1;
+            if (currentNum !== targetNum) {
+                lastSlide = currentNum;
+                slideshow.gotoSlide(targetNum);
+            }
+            else {
+                slideshow.gotoSlide(lastSlide);
+            }
+        }
+    });
+};
+
+/*
+ * Set a hotkey "u" to navigate backwards in time.
+ *
+ * If you get on the wrong slide, this might be the easiest way to get back on track. This is what
+ * you might expect the browser's "Back" button to do if it worked.
+ */
+(function() {
+    var history = [];
+    var skip    = false;
+    slideshow.on('hideSlide', function(slide) {
+        if (skip) {
+            skip = false;
+            return;
+        }
+        history.push(slide.getSlideIndex() + 1);
+    });
+    document.addEventListener('keydown', function(e) {
+        if (e.which === 85 /* [u]ndo */) {
+            var lastNum = history.pop();
+            if (lastNum) {
+                skip = true;
+                slideshow.gotoSlide(lastNum);
+            }
+        }
+    });
+})();
+
diff --git a/slides.html b/slides.html
new file mode 100644 (file)
index 0000000..37678e6
--- /dev/null
@@ -0,0 +1,501 @@
+<!DOCTYPE html>
+<html><head><meta charset="utf-8"><title>Bring Your Own User-Agent</title><link rel="stylesheet" href="css/common.css"><link rel="stylesheet" href="css/slides.css"></head><body><textarea id="source">
+
+class:  center, middle
+name:   title
+
+# Bring Your Own User-Agent
+
+???
+Hi, I'm Chaz.
+
+I want to talk to you about HTTP user agents.
+
+Some time ago I had an idea and I wrote a module and put it up on CPAN.
+
+I don't think anyone knows it exists because I didn't promote it at all. I didn't even really talk to anyone about it.
+I'm not a very social person.
+
+Anyway, I think it *might* actually be a good idea so I'm going to share it now.
+
+---
+class: center, middle
+
+## The problem.
+
+???
+So here's the problem that I wanted to solve.
+
+---
+class: img-map, center, middle
+
+![WebService modules on CPAN](img/webservice-on-cpan.png)
+
+???
+
+There is a whole class of so-called "web service" modules on CPAN that provide a nice perly interface for interacting
+with... web services.
+
+All kinds of things, from...
+
+---
+class: img-map, center, middle
+
+![Twilio module](img/twilio.png)
+
+???
+to
+
+---
+class: img-map, center, middle
+
+![Ontario Power Generation module](img/opg.png)
+
+???
+the Ontario Power Generation website.
+
+---
+class: img-map, center, middle
+
+![WebService modules on CPAN](img/webservice-on-cpan-circled.png)
+
+???
+Most of these modules congregate here.
+
+---
+class: center, middle
+
+## `WebService`
+
+--
+## `Net`
+
+--
+## `WWW`
+
+---
+class: center, middle
+
+## `WebService`
+## <strike>`Net`</strike>
+## <strike>`WWW`</strike>
+
+???
+PSA: For new stuff, prefer the `WebService` namespace for such modules.
+
+---
+class: center, middle
+
+## `WebService` modules are useful.
+
+???
+Even though a lot of APIs nowadays are RESTful which may be easy to use with just your favorite user agent, these
+modules often take care of some of the tricky or boring details, like:
+
+- authentication
+- paging
+
+Details that are important but you don't want to read through the API documentation to figure it out.
+
+---
+class: center, middle
+
+## But
+
+???
+And here's the problem...
+
+---
+class: center, middle
+
+## These modules are **tightly-coupled** to specific user agents.
+
+### ;-(
+
+---
+class: center, middle
+
+![Dependencies](img/deps1.png)
+![Dependencies](img/deps2.png)
+![Dependencies](img/deps3.png)
+![Dependencies](img/deps4.png)
+![Dependencies](img/deps5.png)
+
+???
+Most of them use `LWP` or `HTTP::Tiny`.
+
+---
+class: center, middle
+
+## This has problems.
+
+???
+Now I'm going to try to convince you that this is a problem.
+
+---
+class: center, middle
+
+## Problem #1
+
+### How to configure the user agent...
+
+???
+User agents typically have defaults and so may not need to be configured, but what if the user needs the user agent to
+support proxying, caching, TLS, or shorter timeouts?
+
+If the webservice package is *composing* (or wrapping) a user agent, then the webservice package needs to expose all of
+the ways that the user agent can be configured.
+
+---
+class: ex-code
+
+```perl
+use WebService::Whatever;
+
+my $ws = WebService::Whatever->new(verify_SSL => 1);
+
+$ws->timeout(10);
+
+my $resp = $ws->account_info;
+
+...
+```
+
+???
+So, one way this has been solved is for the webservice to expose all the attributes and knobs needed to configure the
+internal agent.
+
+But that's terrible.
+
+---
+class: ex-code
+
+```perl
+use HTTP::Tiny;
+use WebService::Whatever;
+
+my $ua = HTTP::Tiny->new(verify_SSL => 1);
+
+*my $ws = WebService::Whatever->new(ua => $ua);
+
+$ua->timeout(10);
+
+my $resp = $ws->account_info;
+
+...
+```
+
+???
+So someone remembered that dependency injection was a thing, so now we have modules that let you pass in your own user
+agent.
+
+Big improvement!
+
+---
+class: ex-code
+
+```perl
+*use Mojo::UserAgent;
+use WebService::Whatever;
+
+*my $ua = Mojo::UserAgent->new(insecure => 0);
+
+my $ws = WebService::Whatever->new(ua => $ua);
+
+$ua->connect_timeout(10);
+
+my $resp = $ws->account_info;       # ;-(
+
+...
+```
+
+???
+But I can't just plug in any user agent I want! If the webservice module was written for `HTTP::Tiny` or any other user
+agent, it's expecting that I'm going to pass it the kind of user agent it wants.
+
+This makes me sad.
+
+---
+class: center, middle
+
+## Let the user decide.
+
+???
+I think the user writing a program should decide which user agent to use.
+
+After all, they're the ones who know what the requirements of their app are.
+
+If I'm writing a program that needs to use the least amount of resources, and I want to use a webservice that is coupled
+with a *not* tiny user agent, then I'm out of luck.
+
+Or if somebody wrote a great webservice module using a blocking interface like `HTTP::Tiny` or `LWP` that I want to use
+but my program is event-driven and so can't block, then I'm out of luck again.
+
+So then what, are we just going to write separate webservice modules for each user agent?
+
+---
+
+## [`Mail::SendGrid`](https://metacpan.org/pod/Mail::SendGrid) -> [`HTTP::Tiny`](https://metacpan.org/pod/HTTP::Tiny)
+## [`Mojo::Sendgrid`](https://metacpan.org/pod/Mojo::Sendgrid) -> [`Mojo::UserAgent`](https://metacpan.org/pod/Mojo::UserAgent)
+## [`WebService::SendGrid`](https://metacpan.org/pod/WebService::SendGrid) -> [`Net::Curl::Simple`](https://metacpan.org/pod/Net::Curl::Simple)
+
+???
+Yeah, that's exactly what's up.
+
+What we need is user agent adapter (as in the adapter pattern).
+
+Something that has an inteface that module writers can code against and then translates the request and response
+appropriately for whatever real user agent is provided.
+
+---
+
+## [`HTTP::Any`](https://metacpan.org/pod/HTTP::Any)
+
+???
+I searched CPAN and found just such a thing!
+
+--
+#### But it has fatal flaws...
+
+???
+in my opinion. (No offense to this module's author.)
+
+--
+### 1. It provides its own *new* interface.
+
+???
+- And nobody wants to learn yet another user agent interface.
+- And it's a callback interface in order to support non-blocking user agents,
+
+But having to set callback functions if you're not actually using a non-blocking user agent is kinda clunky.
+
+--
+### 2. It doesn't support many user agents.
+
+???
+only `LWP`, `AnyEvent`, and `Curl`.
+
+--
+### 3. It doesn't actually provide a common interface.
+
+???
+so it's not really usable as an adapter.
+
+---
+class: center, middle
+
+## I wrote a module to fix these problems.
+
+---
+class: center, middle
+
+## [`HTTP::AnyUA`](https://metacpan.org/pod/HTTP::AnyUA)
+
+???
+This one is different because it has a "UA" at the end.
+
+It's also a true HTTP user agent adapter providing a common interface.
+
+---
+## [`HTTP::AnyUA`](https://metacpan.org/pod/HTTP::AnyUA)
+
+### 1. Uses the `HTTP::Tiny` interface.
+
+???
+- So not much new to learn.
+- And you don't have to use callbacks if your user agent is non-blocking.
+
+--
+### 2. Supports at least six user agents.
+
+.col[
+- [`AnyEvent::HTTP`](https://metacpan.org/pod/AnyEvent::HTTP)
+- [`Furl`](https://metacpan.org/pod/Furl)
+- [`HTTP::Tiny`](https://metacpan.org/pod/HTTP::Tiny)
+]
+.col[
+- [`LWP::UserAgent`](https://metacpan.org/pod/LWP::UserAgent)
+- [`Mojo::UserAgent`](https://metacpan.org/pod/Mojo::UserAgent)
+- [`Net::Curl::Easy`](https://metacpan.org/pod/Net::Curl::Easy)
+]
+
+???
+Plus any user agent that inherits from any of these in a well-behaved manner should also work.
+
+--
+### 3. Provides a *common* interface.
+
+???
+which, like I said, is the `HTTP::Tiny` interface.
+
+---
+class: ex-code
+
+```perl
+has ua => (
+    is       => 'ro',
+    required => 1,
+);
+
+has any_ua => (
+    is       => 'lazy',
+    default  => sub {
+        my $self = shift;
+        require HTTP::AnyUA;
+        HTTP::AnyUA->new(ua => $self->ua);
+    },
+);
+```
+
+???
+A webservice module implementing this looks something like this.
+- Allow the user to pass in a useragent. You could also default to `HTTP::Tiny` or something if you wanted the attribute
+  to be optional.
+- Then you construct an `HTTP::AnyUA` instance and pass it the useragent.
+
+---
+class: ex-code
+
+```perl
+sub account_info {
+    my $self = shift;
+
+*   my $resp = $self->any_ua->get(
+        $self->base_url . '/account',
+        {
+            headers => {
+                authorization => $self->auth,
+            },
+        },
+    );
+
+    return $resp;
+}
+```
+
+???
+The webservice methods then use the `HTTP::AnyUA` to make requests using the same args and response that `HTTP::Tiny`
+has.
+
+---
+class: ex-code
+
+```perl
+my $ua = HTTP::Tiny->new;
+
+my $ws = WebService::Whatever->new(ua => $ua);
+
+my $resp = $ws->account_info;
+```
+
+???
+A **user** of the webservice module would look like this.
+
+You just provide the useragent to the webservice.
+
+---
+class: ex-code
+
+```perl
+my $ua = LWP::UserAgent->new;
+
+my $ws = WebService::Whatever->new(ua => $ua);
+
+my $resp = $ws->account_info;
+```
+
+---
+class: ex-code
+
+```perl
+my $ua = Mojo::UserAgent->new;
+
+my $ws = WebService::Whatever->new(ua => $ua);
+
+my $resp = $ws->account_info;
+```
+
+---
+class: ex-code
+
+```perl
+my $ua = 'AnyEvent::HTTP';
+
+my $ws = WebService::Whatever->new(ua => $ua);
+
+my $resp = $ws->account_info;
+```
+
+---
+class: ex-code
+
+```perl
+my $ua = 'AnyEvent::HTTP';
+
+my $ws = WebService::Whatever->new(ua => $ua);
+
+my $resp = $ws->account_info;
+
+# {
+#     success => 1,
+#     url     => "https://whatever/account"
+#     status  => 200,
+#     reason  => "OK",
+#     content => "{...}",
+#     headers => {...},
+# }
+```
+
+???
+The response from `HTTP::AnyUA` always looks like an `HTTP::Tiny` response, regardless of which user agent the user
+provides.
+
+In this case, my "whatever" webservice is just passing the raw response back to the user, but a more useful service will
+decode the response content.
+
+And, in the case that the user provides a non-blocking user agent, then instead of returning a hashref with the normal
+`HTTP::Tiny` response, it returns a `Future` object that resolves to a hashref with the normal `HTTP::Tiny` response. So
+you know what to expect.
+
+---
+class: center, middle
+
+![HTTP::AnyUA diagram](img/http-anyua-diagram.svg)
+
+???
+I think this is pretty cool already, but I'll show you one more thing before I get kicked off that's even cooler...
+
+---
+class: center, middle
+
+![HTTP::AnyUA with middleware diagram](img/http-anyua-middleware-diagram.svg)
+
+???
+You can write components that work for any user agent and plug them in. I've written only a couple such components, one
+to time the request takes and another to ensure a proper 'content-length' header is set.
+
+Middleware components can do anything, even short-circuit and not actually call the user agent.
+
+I started writing a caching component. This middleware is taking me awhile to write because I want it to be
+`RFC-7234`-compliant (and my interest jumps around), but it would be cool because not every user agent has a decent
+cache.
+
+With HTTP::AnyUA, I just need to implement the cache once and it works for all of them.
+
+---
+class: center, middle
+
+## Conclusion
+
+???
+If you're writing a module that needs to *use* an HTTP user agent but otherwise has no reason to prefer one over
+another, consider using `HTTP::AnyUA` or something like it.
+
+---
+class: center, middle
+name:  last
+
+### Thanks.
+
+</textarea><script src="https://gnab.github.io/remark/downloads/remark-latest.min.js"></script><script>var slideshow = remark.create({countIncrementalSlides: true, highlightLanguage: '', highlightLines: true, highlightStyle: 'hybrid', ratio: '16:9', /*slideNumberFormat: '',*/ navigation: {scroll: false, touch: false, click: false}})</script><script src="js/common.js"></script><script src="js/slides.js"></script></body></html>
+<!-- vim: set ts=4 sts=4 sw=4 tw=120 et ft=markdown nowrap: -->
This page took 0.042883 seconds and 4 git commands to generate.