tenderlove / the_metal

A spike for thoughts about Rack 2.0
518 stars 27 forks source link

Thoughts on immutable Request/Response structures (especially headers) #2

Open mwunsch opened 10 years ago

mwunsch commented 10 years ago

Just kind of spitballing here. Is likely incoherent.

Applications should be dealing with streams.

Instead of having Response#write_head (private) write out to the buffer/socket that the web server creates (following the puma example), have the headers be written into a sequence, something as simple as:

@sequence = []
def write_header(key, value)
  @sequence << Hash[key, value]
end

Then, write_head would reduce this sequence before writing to the socket. This way, the Response object holds both the socket and also the history of headers. In the current implementation, headers are merged at initialization and could be duplicated in write_head. By keeping the full history of Header additions and reducing them down before writing to the socket, you get the ability to keep an audit history of middleware changes.

The same could be done to Request headers. Instead of delegating to the grab-bag of env-headers, the webserver's responsibility is, before call is invoked, to reduce the changeset of headers.

I think this is in keeping with the above stated "stream" maxim, except you're not just reading from and writing to a single socket, you are writing to a history of header changes that get reduced and written to the socket at call.

I realize there are memory implications, and that this also only means that header changes can be additive, but that is the case with the current implementation as well. And maybe I've also been getting too into Clojure...

tenderlove commented 10 years ago

It's an interesting idea, though @sequence is still mutated. I think it's fine to do something like this, but it's just an implementation detail of the Response object. This would be a convenient way to store headers internally in the response object when people set a header multiple times (e.g. setting a cookie), but it's still "mutating" the response object.

jeremy commented 10 years ago

Seems like different levels of abstraction. A response built on top of this would reduce its headers before committing the underlying res.write_head ...

mwunsch commented 10 years ago

It is an implementation detail, but you could also create a new Response every time a write is called and return that to the application. Each response is the handler to the socket + new header additions. Riffin a little bit:

class Response
  def initialize(status, headers, buffer, socket)
    @status = status
    @buffer = buffer
    @socket = socket
    @headers = [defaults].tap {|h| h << headers } 
  end

  def write(chunk)
    @buffer.write
     self.class.new(@status, {content_length: chunk.length}, @buffer, @socket}
  end

  def finish
    # @headers reduced and written to @socket
    # @buffer copied to @socket
    @socket.close
  end

end

So a Response object in this implementation would be the history of headers to present and a pointer to a writeable stream. At finish is when things are written out. This finish step could be split up, as in your implementation.

tenderlove commented 10 years ago

Doesn't this mean that calling write wouldn't actually write to the socket? How would we do streams?

mwunsch commented 10 years ago

Still thinking this through in a way that upholds the goals you set forth, and retains a semblance of the old Rack API.

Maybe, depending on say the request headers, @buffer just becomes a transparent reference to @socket or the @buffer becomes optional... and maybe there's a flag that says whether or not the headers have been written to the socket?