闭合式Web应用栈:冥想一则
Clojuring the web application stack: Meditation One

原始链接: https://www.evalapply.org/posts/clojure-web-app-from-scratch/index.html

Aditya Athalye 的《Clojure Web 应用栈:第一篇冥想》探讨了在 Clojure 的库中心生态系统中构建 Web 应用。与依赖规范框架的其他语言不同,Clojure 要求理解 Web 框架架构和应用架构的“是什么、为什么、如何、在哪里”。文章强调了一个以 Ring(用于 HTTP 抽象)和 Jetty(作为嵌入式应用服务器)为中心的栈,提倡组合式方法而非单体框架。它对比了“框架”思维方式(其固有的权衡包括厂商锁定和泄漏的抽象)与 Clojure 组合库的理念。作者主张从简单的 Ring + Jetty + Compojure + Hiccup + next-jdbc 栈开始,并通过 HTMX 增强前端,并鼓励使用示例应用程序和现代的“自带电池”栈(如 kit-clj 和 biffweb)进行实验。文章的核心论点是,Clojure 的库优先方法提供了无与伦比的控制和定制能力,从而促进了对 Web 应用架构的更深入理解。

Adityaathalye 分享了一篇关于使用 Clojure 构建 Web 应用的文章,文章主张通过组合生产级别的库来构建应用,而不是依赖单体框架,这源于一次关于 Biff 框架的讨论。他认为这种“自己动手构建技术栈”的方法,虽然和传统框架一样有优缺点,但这却是 Clojure 的典型方式。 评论者 andersmurphy 对这篇文章表示赞赏,并强调了在自定义的 Clojure 技术栈中轻松替换组件的便利性。他们讲述了如何轻松地在 Jetty、Aleph 和 HTTP Kit 等 Web 服务器之间切换,只需极少的代码改动,因为它们共享相同的接口。这种方法鼓励开发者识别可配置区域并构建微型技术栈,从而减少对第三方框架的依赖,方便添加新功能。 Adityaathalye 回复强调了 Clojure 组件的可替换性和稳定性是其在业务方面的重要优势。他指出,虽然这种方法需要付出努力,但精心设计的模块和使用“system”库可以简化这个过程。即使是简单的多方法,例如路由器,也可以作为生产级别的组件,适用于较小的应用程序。

原文

Clojuring the web application stack: Meditation One

In a land bereft of a canonical "killer app" web framework or two, one must think about the what, why, how, where of all the moving parts. Out here, one must become a student of web framework architecture in addition to web application architecture. For here, in Clojure-land, the two are one. ☯


  • This post is big! Skip whatever bores… Follow the nice ToC!

  • It is an "I to I" explanation I wish I had long ago. I've referenced getting started material and "batteries included" Clojure web stacks toward the end, which may be the most practically useful section of this post.

  • Errors and inaccuracies are all mine . If you spot any, please write to complaints @ my domain. (And if you want to say nice things, write to compliments @ my domain :). Feel free to discuss on HackerNews (here) and r/Clojure (here).

  • A basic grounding in the Clojure programming language is assumed . Familiarity with web development will help.

  • I will stick to discussing the Clojure web application stack in relation to classical Web Frameworks. Primarily .

  • Yes, a Meditation Two is in draft hell. It is about Getting Pretty Deep In The Woods. If it sees the light of day, it will also be a giant post. Lambda help us.

Multitudes of sworn "Rails developer"s, "Laravel developer"s, "Django developer"s, "Next.js developer"s and suchlike throng the universe… Why?

Novices don't know they don't know.
The Framework saves them from themselves.
So they survive.

Intermediates know enough but would rather not have to.
But it is a living. A person must eat.
So that's that. RTFM.

Experts know enough to care deeply .
The Framework is muscle memory; all the hacks,
the tricks, the deep dark secrets.
It bends to will. Mostly.

Masters know enough to not roll their own.
And yet… sometimes they do.
A new cycle begins.

Once upon a time, there was one.
WebObjects.
Now they are numberless.

Picture the Clojure web stack this way… to a good first approximation:

  • Our business logic (written in Clojure),
    • relies on a bunch of Clojure libraries (frequently, ring-clojure),
      • that know how to use an application server (Jetty).
  • In the simplest deployment model, this picture fits within a single compute instance (e.g. a PC, Cloud VM, or "Serverless" container).
|                      |
|  The bulk of our     | Our Business Domain's
|  application logic,  | data representations
|  written in Clojure. | {} [] #{} '() 'x 42
|                      |
+- - - - - - - - - - - + -- RING SPEC -- -- -- -- -- --
|                      |    ^       |
|  The subset of Ring  |    |       |
|  libraries we use    |   { } REQUEST hash-maps
|  as-provided, and    |    |       |
|  as utilities to     |    |       |
|  make custom handlers|    |       |
|  and middleware in   |    |      { } RESPONSE hash-maps
|  Clojure.            |    |       |
|                      |    |       v
+----------------------+ -- RING SPEC -- -- -- -- -- --
|    CLOJURE MAPS      |  Clojure facing interfaces
|                      |  (functions, hash-maps)
+- ring.adapter.jetty -+- - - - - - - - - - - - - - - -
|                      |  Java facing interfaces
|    JAVA OBJECTS      |  (Servlet API, Jetty config API)
+----------------------+ ------------------------------
|       Jetty          |  (deserialize ^)
| (Application server, |  HttpServlet Objects
|  "embedded" mode.)   |  (serialize   v)
+----------------------+ ------------------------------
                          (Plaintext HTTP Responses v)
     NETWORK BOUNDARY facing the WWW side
                          (Plaintext HTTP Requests ^)
+----------------------+ ------------------------------
|  Public Web Server   |  (deserialize ^)
| (for SSL termination |  HTTP Objects
|  static assets etc.) |  (encrypt, serialize v)
+----------------------+ ------------------------------
                          (HTTPS Responses v)
   NETWORK BOUNDARY with the Public WWW
                          (HTTPS Requests ^)

But before getting too practical, an indulgent philosophical interlude.

I think frameworks are a form of industrial automation (of choices, behaviour, workflows, detail and so forth). Perceived this way, they embody the tradeoffs of industrial automation .

Tim Ewald makes astute observations about this phenomenon, in his talk "Clojure: Programming with Hand Tools". As he remarks, pervasive use of automation has the insidious quality of changing how we view the world and how we perceive problems. To a mind invested in a framework, all web software will look irresistibly framework shaped. Squint just right, and the answer reveals itself. And they may very well be right. Until they are not.

The Clojure world does it the hard way; viz. the not-framework way.

What does a Killer App kill?

Building web applications is arguably the most well trodden path into the software industry. Naturally. Many of the most valuable companies on Earth are web applications. Well-heeled web forms dominate the world.

As the Web evolved, the Web Application Framework gained status as the "killer app" of any respectable programming language ecosystem. Framework makers work hard to serve the multi-faceted, ever-evolving demands on the Web Application. Their products contain time tested ideas; accumulated knowledge of many minds, battle scars from full contact Kumite with the Wild Wild Web. In polite society, we call these scars "design patterns".

Knowing a framework well can liberate a person from the rabbit holes of composing software to solve for things like:

  • App architecture (MVC) and code layout (project templates)
  • HTTP request/response parsing and handling
  • Routing and dispatch
  • HTML templating and/or rendering
  • API design and use (HTML / text / JSON etc.)
  • Form handling
  • Data serialisation / deserialisation
  • Sessions
  • Persistent connections (websockets, long polling)
  • Database connector / driver (e.g. JDBC)
  • Database ORM
  • Sending emails
  • Managing job queues
  • Configuration (via. environment variables, files, remote sources)
  • App runtime lifecycle (dependency injection, starting/stopping etc.)
  • Security (encryption, data sanitisation etc.)
  • Authentication and/or Authorization
  • Application logging
  • Monitoring (with metrics and/or probes to monitor the live runtime)
  • Building and Packaging
  • Deployment (new-age frameworks)
  • Boilerplate and glue code required to make all these work together.
  • Developer Experience (framework-aware tools and IDEs are life savers).
  • More…

That said, as with all things, TANSTAAFL.

What's the catch?

Tradeoffs of using a framework stem from the degree of control ceded to it and its ecosystem (ideas, plugins, packages, tools etc.). One accepts a form of vendor lock-in, in lieu of anticipated benefits. Some tradeoffs are:

  • Fixed core architecture.
    Any framework's architecture is fixed; unchangeable from the outside. e.g. If you don't like the router or ORM or template engine built into your chosen framework, can you just rip them out and put in other choices (for API or performance or security reasons)? No, you would have to migrate to an entire alternate framework, and bet that this one will fulfill all your current and (unknown) future requirements.

  • Leaky abstractions.
    The design choices and mental models of the framework and/or plugin authors inevitably flow into the app. It comes to rely on how they encoded explicit and implicit behaviours, software design patterns, opinions about deployment and operations etc. The more one uses, the stronger it binds.

  • Upkeep.
    You own the design and upkeep of the whole composite, especially your self curated and/or bespoke parts that patch, adapt, or work around those leaky abstractions. Unavoidable framework updates are par for the course (e.g. security patches and/or access to new functionality). Even with no custom parts, app makers must carefully update all off-the-shelf plugins and tools to remain API - compatible. And then also update their own application code to be compatible with any updated third-party API.

  • Production expertise.
    Debugging production issues can rather quickly become about grokking the inner workings of the framework. Meaning, sooner or later one must become a student of that specific web framework.

  • Choices are an expert matter.
    Though popular language ecosystems have a canonical web framework or two, all have a plethora of alternatives. Choosing between frameworks is an expert matter. Newcomers are directed to the most canonical one for good reason. Each alternative embodies a concrete set of tradeoffs, community support, lore and so forth, all opaque to the newbie, and difficult to parse even for an expert outsider. This is perhaps why teams get built around a framework and one or two framework experts.

In Clojureland you stack libraries and the odds yourself

The culture here strongly prefers libraries over frameworks. Here is a quick overview of what we have in our web ecosystem, and the implications thereof.

The Ring world

The Ring project, by James Reeves (a.k.a. weavejester), is the Clojure ecosystem's canonical collection of HTTP libraries. Its design choices have a far-reaching effect on the whole Clojure web ecosystem. So it's worth becoming familiar with Ring.

James also created hiccup (HTML rendering) and compojure (routing), which used with ring and Clojure's standard library are enough to create a functional traditional multi-page web application, backed by the file system. To use a database, all we need is a library like next-jdbc. And making a "modern-feeling" web UI has become easy with HTMX, which "just works" with hiccup.

IMO, most web apps can start this way (and can probably stay this way).

Framework-like web stack projects

Several framework-like web stacks also grace Clojureland, viz. Fulcro, Biffweb, Kit (successor to Luminus), Duct, Pedestal etc. However, unlike object oriented frameworks that are fully integrated monolithic systems, these are open-ended sets of libraries that represent the project developer's opinion of how to build web applications. Newer projects like sitefox and donut aim to be more "fully integrated" frameworks. Single Page Application enjoyers may find hoplon cool. And if you want truly novel systems, check out hyperfiddle/electric, and Rama by Red Planet Labs.

Dependency injection for those in the know

Another approach is to use something like a dependency injection framework to connect and orchestrate all our app's moving parts through some common system. Libraries like component, integrant, mount, donut-system serve this purpose. These are favoured by people who already have specific opinions about what set of libraries and pieces of infrastructure they need (and why).

That's not all folks

We haven't even begun to enumerate a constellation of other libraries needed for databases, caches, security, logging, monitoring, queues, jobs and so forth.

Even otherwise seasoned programmers, who are new to Clojure, can struggle to find their bearings amid this dizzying array of choices.

"There is no spoon architecture"

Alas, not only is there no obvious One True Framework, there is no obvious One True Framework Architecture either. This adds to every Clojure newcomer's struggle, even grizzled web veterans. As a thought experiment, I feel a Rails developer will find it easy to make sense of a Django or Laravel project, versus any of the apps built with tools we have in the Clojure ecosystem.

Popular web frameworks, going all the way back to WebObjects (1996) are object oriented GUI software; products of convergent evolution along common industry-wide OOP patterns. They are designed for use via Public APIs. Core parts are welded together. Thus, a competent Rails developer parachuting into a Django project can reasonably expect to follow their nose down familiar-feeling Class hierarchies and method chains, across familiar Model, View, Controller structures.

Why become a student of the web stack?

The Clojure world, though built with Java for the JVM, .Net for the .Net CLR, and Javascript for node and browser engines, departs wildly from those underlying Object Oriented foundations.

This fact deeply influences everything, including making web apps. So, although freshly-minted intrepid Clojurians will do well to pick the Ring stack, or one of the popular "starter kits", we must consciously become students of web framework architecture too.

For out here, the problem of making a web application is also the meta-problem of composing a bespoke web stack.

Many wonderful resources teach Clojure/ClojureScript web development. However, I struggled to build a coherent picture, until I worked out a first-principles model, upon which to build my understanding. So here are the bare essentials, to motivate further learning, using material I reference later.

A web app is just a polymorphic dispatcher

Think… what does a web app reeeeeally do?

HTTP request ->
  /pattern-1/ method-1
  /pattern-2/ method-2
  /pattern-3/ method-3
              -> HTTP response

Shell scripting enjoyers will immediately think of AWK programs, and their design sense would not be wrong. But there is more to the story, of course. For a "pattern" is a set of one or more pieces of information culled from HTTP requests, most crucially the URI and the HTTP verb.

HTTP request ->
  GET    /uri-1/ getter-method
  PUT    /uri-1/ putter-method
  POST   /uri-1/ poster-method
  PATCH  /uri-1/ patcher-method
  DELETE /uri-1/ deleter-method
                 -> HTTP response

This pattern tempts us to construct an HttpObject, and is arguably why modern-day OOP style appears to be a natural fit. Yes, the tiniest piece looks like an Object. And yes, the whole web app as a system is very Object Oriented. However, IMHO, the monolithic design of frameworks is rooted in having to use the smallest datum as some concrete HttpObject, instead of generic data.

Clojurists favour generic data over concrete objects and composition over inheritance, because building with composable parts gives us almost unlimited control over the shape, size, and sophistication of our application. The initial learning curve pays off over time, as we get to keep simple apps dead simple, and to ensure not-so-simple apps are only as complex as they need to be.

We build our polymorphic systems using Functional Programming parts.

With this in mind, we construct the core intuition of the anatomy of Clojure web apps, which lives in the heart of ring-clojure…

Ring with Jetty is the classic combo

Refer back to the Big Picture.

The Ring project is a crowd favourite for production Clojure web apps. It established the Ring specification along with the request / response handling model that many other Clojure web libraries support, or complement.

Jetty is a popular server of choice in the Clojure community. It is generally used as an "Application Server" in "embedded" mode, i.e. we put the server inside our web application, as a regular library dependency. We can alternately invert the model and run Jetty in "standalone" mode, as a "container" runtime, i.e. we put our application inside the Jetty server.

We will briefly peek at the Servlet API in a later section, as that is the common base for both modes of operation. But this post assumes we run our app in the community-preferred way. By and large, Clojurians prefer the embedded jetty way over the servlet container way .

Bare minimum ring.adapter.jetty web app

Now we make a bare-minimum web app where the handler function is a catch-all method. It will return a string containing the request information for any HTTP request made to any route. This seemingly pointless code is actually useful to check that your project is set up right. Use it as a starter template.

Bare minimum directory structure

$ tree . # root of our project directory
.
├── deps.edn  # project configuration
├── classes   # target for compiled code
└── src
    └── first_principles
        └── core.clj # our bare minimum app

Bare minimum library dependencies

Our deps.edn file contains this configuration; only Clojure and the Jetty adapter library from the Ring project. We use the Jetty adapter as-provided, to avoid rewriting a whole bunch of code to do Java interop and implement the Ring specification. We rely on these as standards, so we can assume they are available as a given.

Clojure CLI.

Compile and run as a Java process.

$ clj # in the root directory of our project
Clojure 1.11.3
user=> (compile 'first-principles.core)
first-principles.core

# Ctrl-d to exit the REPL, then run the compiled code

$ java --class-path $(clj -Spath) first_principles.core

Or directly from the REPL session.

$ clj # in the root directory of our project
user=> (compile 'first-principles.core)
first-principles.core

user=> (first-principles.core/-main) ; start the server
SLF4J: No SLF4J providers were found.
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See https://www.slf4j.org/codes.html#noProviders for further details.
#object[org.eclipse.jetty.server.Server 0x6331250e "Server@6331250e{STARTED}[11.0.20,sto=0]"]

user=> ; Ignore the SLF4J messages. Ctrl-d to exit, when done.

Bare minimum HTTP requests

Our bare minimum live app responds to any HTTP request. Observe the request maps echoed back, for what changes, and what doesn't.

  • Try curl http://localhost:3000 , the bare minimum GET request.
  • Try other URI paths, with and without query params.
  • Try any of those combinations with other HTTP verbs e.g. curl -XPOST http://localhost:3000 (or -XDELETE or -XPUT or -XPATCH).

Here is a sample result of a GET request to some made-up path with some arbitrary query parameters.

$ curl -XGET \
       "http://localhost:3000/foo/bar/baz?search=wassup%20world"

echo request: {:ssl-client-cert nil,
:protocol "HTTP/1.1",
:remote-addr "127.0.0.1",
:headers {"accept" "*/*",
          "user-agent" "curl/7.81.0",
          "host" "localhost:3000"},
:server-port 3000,
:content-length nil,
:content-type nil,
:character-encoding nil,
:uri "/foo/bar/baz",
:server-name "localhost",
:query-string "search=wassup%20world",
:body #object[org.eclipse.jetty.server.HttpInput 0x2a91914a "HttpInput@714182986 cs=HttpChannelState@2eae00c0{s=HANDLING rs=BLOCKING os=OPEN is=IDLE awp=false se=false i=true al=0} cp=org.eclipse.jetty.server.BlockingContentProducer@6bac9b71 eof=false"],
:scheme :http,
:request-method :get}

Though small, our "barebones" app is still doing a lot of stuff. To figure out what's going on, let's deconstruct it further.

Bare minimum Ring project derived from first principles

Hint: It's functions all the way down.

Continuing with reference to the Big Picture, I feel like a minimal web application stack must, at the very least, facilitate the following:

  • Interface with the outside world, relative to our application.
  • Interface with us, in the language / domain of said app.
  • Provide creature comforts to automate the drudgery of interpreting HTTP requests and creating HTTP responses.
  • Provide some mechanism to orchestrate and control handler execution. It turns out that the mechanism of handlers alone is not enough to cater to all our request/response needs. We use another mechanism called "middleware".

Interface with the outside world

ring-jetty-adapter is our interface (ref: Big Picture). It is a Clojure wrapper over Jetty's Java APIs.

For us "outside" is the land of Java objects, viz. Jetty's HTTP object model, Servlet interface, and server configuration interface. These bits of the library's "outside-facing" code illustrate how it "adapts" between Jetty and Clojure:

API doc.

Interface with us

ring-jetty-adapter is, again, our interface (ref: Big Picture).

For Clojure programmers, generic Clojure data is our programming model, not custom objects. So the adapter's Clojure facing side lets us:

  • Configure Jetty from our Clojure app, using plain Clojure hash-maps.
  • Manipulate HTTP requests and responses from our Clojure app as plain Clojure hash-maps.
  • Rely on a standard specification of requests and responses as hash-maps that mirror the HTTP standard.

The Clojure data version of Ring's request/response specification is human and machine readable (within our Clojure runtime).

Ring specification and compare it with the code in these two namespaces of the main ring project: ring.adapter.jetty and ring.util.jakarta.servlet.

Provide HTTP creature comforts

… to automate the drudgery of interpreting HTTP requests and creating HTTP responses. Illustrating this will require a bit of set up.

First, I'll copy down our barebones app code, and slightly refactor it so -main looks more like it would in a production Clojure app.

ring.util.response functions, for example. Also check out its sibling ring.util.* namespaces. These utilities address i/o, requests, mime-type, parsing etc.

ring.middleware.* API docs). These are also "just functions", like the one below. We will see how and why such middleware work, but first a bit on why we need this mechanism at all.

compojure in your first little web app or three. Swap it out for another library later, to explore other ways of routing.

Here is the bare minimum intuition for routing.

We pull apart our monolithic generic-handler into little handlers and wire them back together with our poor man's router using something more flexible than a plain old case expression.

First, a copy of the generic-handler for quick reference.

in the thread)

I suggest don't "roll your own", at the outset. Make old-skool web apps with Ring + Jetty + Compojure + Hiccup + next-jdbc stack, which is fine for ordinary production use. Sprinkle in some HTMX for fancier web frontend. Rest assured that it is possible to swap out any of these later, if specific needs arise.

Speed run through small demos

It's a good idea to work through existing examples and demo apps, over a weekend or two. This should give you enough finger feel to choose one of the state-of-the-art stacks listed later in this section, or roll your own too.

  • Review the docs and wiki of the main Ring project, and keep these handy. Ring is a fantastic reference. I notice weavejester has been adding example projects too, so you may like to follow that repo.

  • Speed-code through Eric Normand's "Learn to build a Clojure Web App" tutorial. I feel like my post is a nice conceptual complement to his more practical post. In it you'll learn some useful real-world tricks and techniques that we use in day-to-day web development.

  • Watch Nir Rubinstein live code a similar tiny demo web app at Wix Engineering Tech Talk. He walks us through his thinking, various little details of the Clojure web development workflow, as well as some comparisons with the more popular Object-oriented approaches.

Do more hands-on practice

I like to copy example apps (type them out from scratch in my own words) . Here are some good options.

  • Sean Corfield's usermanager-example demo app, and its variants linked in the README.

  • Learn the tricks of web development workflows used by Clojurians, by watching them code example apps using real-world workflows.

    • I quite like Andrey Fadeev's video tutorial series, Building a real-world Clojure application from SCRATCH.

    • I'm not a full-stack developer and don't generally need ClojureScript, but I've enjoyed the video series by Kelvin Mai: Full Stack Clojure Contact Book and by Daniel Amber: look up the "full stack" videos in his assorted collection of Clojure videos.

    • Parens of the Dead is a terrific screencast series of zombie themed games written with Clojure / ClojureScript. Watch two expert Clojure programmers teach newcomers how to build everything from scratch, with clear explanations, run-time foibles, and some friendly banter. As of this post, the series is still undead!

  • Good paid material is available too (no affiliation with any).

Review the current state of the art

The afore-linked references use many web stack pieces not seen in this post. These pieces are specialised solutions (libraries) for problems like routing, content negotiation, security, safe templating, safe SQL etc.; each solving for its particular domain of devilish edge cases.

The whys and wherefores thereof are being meditated upon in the next post with the working title "Getting Pretty Deep In The Woods".

Or posts.

Egad.

An activity diagram to describe the resolution of the response status code, given various headers. Image source: webmachine, CC By SA 2.5, Alan Dean.

Oh how your states go round and round,
Webmachine.
Spring, summer, rain, fall, winter, spring.

联系我们 contact @ memedata.com