-
Notifications
You must be signed in to change notification settings - Fork 18
The Structure of a Hyperloop Application
unless you want it to be.
Hyperloop is a collection of Ruby classes packaged in several gems so that you can quickly (and we mean quickly) create great applications, using just Ruby. These classes allow you to structure your code so that it's debuggable, reusable, maintainable, and best of all short and sweet. If you want to call that a framework, that is okay, but it's also useful to think about how each of the Hyperloop elements works as an independent part of the application. That way you can pick and choose how and when to use each piece of Hyperloop to your best advantage.
So without further ado, let's get stuck into the Structure of a Hyperloop Application.
Sitting at the base of your Application are one or more Stores. This is the same "Store" that Flux talks about. In Hyperloop, Stores are created by subclassing the HyperStore
base class.
A HyperStore is just like any other Ruby class but with a very special feature: they have reactive instance variables or state variables, or simply state.
State variables literally hold the state of your application. State variables work just like regular Ruby instance variables, except that they intelligently inform any of your Components (we will get to them next) that they will need to re-render when the state changes.
Here is a very simple Store which keeps a list of Words. Note that we are only going to have a single Word store, so everything is defined at the class level.
class Word < HyperStore::Base
class << self
private_state list: []
receives :add! do |word|
state.list! << word
end
def list
state.list
end
end
end
The declaration private_state list: [], scope: :class
creates a state variable called list, and initializes it to an empty array.
The declaration receives :add! do ...
creates an action method that receives the add!
action. The method will push a new word onto the list. We tell HyperStore that we are acting on the list by appending the exclamation to the state name. Likewise, by convention action methods names end in the exclamation as well.
To see our words we have the list
method. Notice that list
only reads the state so there is no !
either on our method name or when we access the state.
That is all you have to know about HyperStore. The rest is plain old Ruby code, plus some helper methods to save typing. You can create Stores as simple as the one above or as complex as one that keeps a pool of random users available for display like this example.
Now that we have some state tucked away we will want to display it. In Hyperloop you create your views with Components which are Ruby classes that wrap React.js components. Let's make a simple component to display our Words, and allow the user to add more words to the list.
class ShowWords < React::Component::Base
render(DIV) do
INPUT(placeholder: 'enter a new word, and hit enter', style: {width: 200})
.on(:key_down) do |evt|
next unless evt.key_code == 13
Word.add! evt.target.value
evt.target.value = ''
end
UL do
Word.list.each { |word| LI { word } }
end
end
end
Our component definition is straight forward. The ShowWords
component renders a DIV
which contains an input box, and a list of words.
The input box has a handler for the key_down
event. We ignore all keys except the enter key. When the enter key is pressed, we dispatch the new word to Word's add!
action, and clear the input.
To show the list of words we use Word's list
method and display each word in the list.
As new words are added, our component will re-render because the value of list
will have changed.
Hyperloop Components have all the features of normal React components and more. For the complete description of the DSL (domain specific language) and other details see the documentation.
So far in our example, we have defined the add!
action directly in the Store. It is often useful to structure the actions as separate objects.
- Actions defined as separate objects allow Components to be completely separate from the Store being updated, which can facilitate reuse.
- Business logic, external API calls, and complex sequencing between stores can be packaged inside the Action.
- Defining Actions as separate objects allows Stores to do one thing: manage their internal state.
- As we will see Actions can be used to implement protected logic that should only run on the server.
In the classic Flux pattern, all actions are defined as separate entities. Hyperloop is less opinionated. In cases where the action is clearly associated with a single specific Store, then go right ahead and define it as an action in the store. If on the other hand, the Action has a general meaning which can be described without reference to a particular store or state, needs to run on the server or has complex business logic, then define an Action object.
Let's add a ClearAll
action to our growing application. The meaning is for any store to reset itself to its initial state.
First we define the Action:
class ClearAll < HyperAction
end
Then let's have our Word store class listen for it and empty the list:
class Word < HyperStore::Base
class << self
receives ClearAll { state.list! [] }
end
end
Finally, we will create a new component that uses our ShowWords component and adds a 'Reset' button:
class App < React::Component::Base
render(DIV) do
ShowWords()
BUTTON { 'Reset' }.on(:click) { ClearAll() }
end
end
I hope all this is clear: The ClearAll
(no pun intended) Action breaks the coupling between the App component and the Word store. If we added another store that needed to respond to the ClearAll
action only that store's code would change.
Every Action has an execute
method. By default, this method will dispatch to all the receivers of the Action. For more complex actions we can define our own execute method. This concept is taken directly from the Trailblazer Operation class.
Here we will create an Action that adds a random word using setgetgo.com
class AddRandomWord < HyperAction
param length: nil # length is optional and will default to nil
def length
"?len=#{params.length}" if params.length
end
def url
"http://randomword.setgetgo.com/get.php#{length}"
end
def execute
HTTP.get(url, dataType: :jsonp).then do |response|
Word.add! response.json[:Word]
end
end
end
Our action simply makes the request to "getsetgo", and when the promise resolves will dispatch the new word to Word's add action.
Actions are where all complex business logic and API calls should go. Stores should be kept very simple, and have the minimum action methods needed to update the store.
Let's say we want to keep our own list of words on the server, and we don't want to bring down the entire list of words when the client application starts.
Traditionally this would involve setting up our own internal API, and adding a controller (at least) to our server to deal with the API.
In Hyperloop it's so much easier:
class GetRandomWord < HyperAction
allow_remote_execution
def self.words
@words ||= File.readlines("words_file.txt")
end
def execute
AddRandomWord.words.sample
end
end
The allow_remote_execution
declaration indicates that AddRandomWord
can be called remotely.
If you want to persist a Store you could set up set up some remote Actions running on the server to read and write to your database. Certainly, it would be straightforward, but if you are already using Rails and ActiveRecord it's completely automatic.
Any of authorized ActiveRecord model data will be directly available on the client.
For example, let's convert our Word store to a Word model.
class Word < ActiveRecord::Base
def self.add!(word)
create(text: word)
end
def self.list
all.pluck(:text)
end
end
We included the add!
action method and the list
getter just so the interface would be consistent with our previous examples, both are implemented using the standard ActiveRecord methods create
, all
and pluck
. Of course, you have to create a Rails migration to add the Word table to the database, but as far as coding we are done - well almost done - we still have to define the access policy for our model, but that is coming up next.
Using the ActiveRecord model definitions Hyperloop will construct all stores, and actions needed and will keep all local copies synchronized with the server database. This works not only for simple models and attributes, but also for relationships, and scopes.
Like Stores, your Models should be kept as simple as possible. Move any complex business logic to Actions. Models may contain nothing but some scopes, and relationships.
The Hyperloop ActiveRecord implementation will allow full access to your models from your client, not just create
, but read
, update
and destroy
. So Hyperloop also implements a policy system that prevents unauthorized or accidental access.
Access is based on the concept of the acting_user
, and on channels. The acting_user
represents the current logged in user. Channels represent a broadcast channel between the server and one or more browsers.
Here is a very simple policy that will create an Application
channel attached to all browsers and another policy that will allow the Application channel to create, and read from the Word model.
class ApplicationPolicy
# any browser may connect to the Application channel
always_allow_connection
end
class WordPolicy
# always allow creation of new words
allow_create
# broadcast all attributes of Word to the Application channel
regulate_broadcast { |policy| policy.send_all.to Application }
end
More realistically, of course, we might require a logged in user to create Words and allow administrators to delete words:
class WordPolicy
allow_create { acting_user }
allow_destroy { acting_user.admin? }
regulate_broadcast { |policy| policy.send_all.to Application }
end
When we defined our remote GetRandomWord Action we included the ability for any client to remotely invoke the Action. To restrict this to only logged in users we could add this line to the ApplicationPolicy:
GetRandomWord.allow_remote_execution { acting_user }
This would require that we have a current acting_user
in order to run the Action.
- Your application state goes in Stores and Models. These should be kept as simple as possible.
- Components display the state of your Stores and Models and bind user events to Actions.
- All complex business logic and external API access should be grouped logically into Actions.
- Actions may invoke other Actions either on the client or the server. Server-side actions can accomplish protected activities like accessing secure APIs.
- If authorized your ActiveRecord models are directly accessible on the client. Changes to the database will be synchronized across all clients with permission to see the data.
- Policies control remote action calls, and access to ActiveRecord models.