-
Notifications
You must be signed in to change notification settings - Fork 1
RestrictActor
Text compliant with Leucine version 0.5.x
With SelectActor
we are able to receive messages from a limited
set of actors. However, any actor of that set can send any of
the defined messages. In real world applications this is not what is
happening. It would be better if we are able to restrict where
the letters are coming from on the receiving side per actor. RestrictActor
is exactly made for that purpose. It allows you to restrict the types
of the letters, for each origin, based on the type (not instance).
Let's see how we can define that with an example.
In this example we build a chat bot. After you have applied for an
account at this bot (this may be refused) you can login with name and password
to receive some random text. A completely useless application, but
it will serve the purpose of illustration the workings of the
RestrictActor
.
In this example we have four actors and a console. The console represents the main loop.
Observer that not every actor communicates with every other actor, and not all actors use every letter of each actor. BTW, not all messages send around need to be a separate letter class, there can be reuse of letters. This will all be explained below.
The idea is that on this bot you can request for a account, by typing your 'signup' and your name. If there is room you will receive a password, otherwise you will be rejected. If given a password you may request for new random texts using this password by typing 'text' + name and password. Typing 'help' gives basic explanation. That's it.
If you request a new account, a message is sent to the Register
account which keeps a list of new passwords to issue. If the
list is empty, it requires a new set from the Noise
actor
(and refuses the attempt). If successful, it sends your credentials
to the Access
actor, which is there to verify all registered
accounts.
So if you request for some random text, with name and password
at the Text
actor, that actor in turn, verifies your credentials
with the Access actor. If approved, the Text
actor requires
some random text at the Noise
actor, to send back. If refused
you will be informed.
First, we start with an investigation which letters the actors need to process, i.e. which letters can be received from whom. In a way we start at the back, with the companion object that holds all letters to be received. Should we start with the actor class itself, we should already have a clear picture what we may send, but this is unknown as long a its unclear what we can possible send.
After it is clear which actors may receive an process, the implementation of the receive methods will be an straightforward task
Take a look at the the Text
actor for example. This actor needs
to communicate to the actors Acccess
, Noise
and Anonymous
(for the console). It must receive it a user was allowed in,
receive some random text for a name, and accept a login with
name and password. The latter two are in fact similar messages
which can be combined. Usually this is not a good idea, but
for illustration of the fact that meaning of a letter depends
on the source it works well. So Text
receives only two
types of letters from three sources. If we use the
SelectActor
for this situation, it creates too much
freedom. Therefore we choose RestrictActor
:
/** Companion object where letters and accepted sender actors are defined. We keep no state. */
object Text extends RestrictDefine, Stateless :
/* We only accept letters from the Noise or Access actors or from an anonymous source. */
type Accept = Access | Noise | Anonymous
/* Letter is the base type for all letters: */
sealed trait Letter[Sender <: Accept] extends Actor.Letter[Sender]
/* Letter that contains information about the user, content is sender related. */
case class Lipsum(name: String, content: String) extends Letter[Noise | Anonymous]
/* Letter that verifies if the user was allowed. */
case class User(name: String, allow: Boolean) extends Letter[Access]
Observer that the Lipsum
letter will only be accepted when originating from the
Noise
or Anonymous
senders, and that this is enforced by the type. Likewise
only the Access
actor may send the message User
to the Text
actor.
Subsequently we look at the Register
actor. It receives only two type of letters.
A request for new passwords, and a list of new passwords. The former is send by
the Anonymous
actor (console) and the latter by the Noise
actor. So we can
restrict the letters to these actors each:
/** Companion object where letters and accepted sender actors are defined. We keep state manually. */
object Register extends RestrictDefine, Stateless :
/* We only accept letters from the Noise actor or from an anonymous source. */
type Accept = Noise | Anonymous
/* Letter is the base type for all letters: */
sealed trait Letter[Sender <: Accept] extends Actor.Letter[Sender]
/* Letter that contains new passwords. */
case class Passwords(values: List[String]) extends Letter[Noise]
/* Letter requests a (new) password for the user. */
case class Request(name: String) extends Letter[Anonymous]
This actor can be requested for random texts (ideally synthesized by a quantum noise source ;). The orders are given and returned using a key. Parameters are the number of random words, as well as their maximum length. This can be used for passwords (sort of) and a random piece of text.
/** Companion object where letters and accepted sender actors are defined. We keep no state. */
object Noise extends RestrictDefine, Stateless :
/* Only the Access and Text actors may request for random content. */
type Accept = Register | Text
/* Letter is the base type for all letters: */
sealed trait Letter[Sender <: Accept] extends Actor.Letter[Sender]
/* Letter that requests for size new random char strings. */
case class Request(key: String, size: Int, length: Int) extends Letter[Accept]
Note that, since the letter is used differently depending on the source, this
letter is valid for both types. We could as well used a SelectActor
here as
base type.
The Access
actor checks and stores users. So this always requires 'name' and
'password'. Again, we can utilize one type of letter for both situations,
but we only provide this service to the Register
or Text
actor. The
implementation becomes.
/** Companion object where letters and accepted sender actors are defined. We keep state manually. */
object Access extends RestrictDefine, Stateless :
/* We only accept letters from the Register or Text actors. */
type Accept = Register | Text
/* Letter is the base type for all letters: */
sealed trait Letter[Sender <: Accept] extends Actor.Letter[Sender]
/* A letter to send name and password to me. */
case class Pair(name: String, password: String) extends Letter[Accept]
Now that we have defined all these letters, we can start processing them as messages when they come in. The order in which we discus this is the order of the natural flow of events:
sequenceDiagram
participant U as User
participant S as Service
U->>S: Request new account
alt Room for new user
S->>U: Issue password
U->>S: Login with credentials
S->>U: Welcome quote
else No room for new user
S->>U: Try again later
else Wrong password
S->>U: Account does not exist
end
The extension signature of the RestrictActor
equals that of the other actors.
The first parameter is the companion object, the second an optional name for
this actor. In this actor however, we must be able to send messages to
implementations of the Access
and Noise
actor. These are given as
construction parameters, as they cannot be deduced from incoming messages.
Then, note the signature of the receive
method. This is obligatory for the
RestrictActor
use. The Sender
can be any of the actors this actor
accepts, and thus a subtype thereof, the Letter[Sender]
is the letter that
comes with that actor. To find out which letter and sender go with the message
we have to match them in combination.
The first test is to catch the new users request. The user only supplied a name
and the sender must be Anonymous
. If a letter of this kind arrives, we must
issue the user a new password from the supply
. However, this supply maybe
empty (always at first try). In that case we refuse the user and request for
some new passwords at the same time from the Noise
actor. If there are
passwords available, send one back and remove that from the supply.
The request for new passwords will return eventually with a list of values, which are used to refill the supply.
The last case (catch all) cannot be reached but the compiler is not (yet) clever enough to figure this out.
/** Service that keeps a list of new passwords to be handed out. Starts empty */
class Register(access: Access, noise: Noise) extends RestrictActor(Register,"Register") :
println("Register Actor Started.")
/* Supply of new passwords */
var supply: List[String] = Nil
/* Receive method that handles the incoming requests. */
def receive[Sender <: Accept](letter: Letter[Sender], sender: Sender): Receive = (letter,sender) match
/* A request for new passwords is made. */
case (Register.Request(name),source: Anonymous) =>
/* It may be that we have no passwords left, test this first. */
if supply.isEmpty
then
/* If so, let the user know we do not accept users at the moment */
println(s"Sorry $name, at the moment we do not accept new accounts, please try again later.")
/* and at the same time order new passwords. */
noise ! Noise.Request("",3,4)
else
/* If we have passwords available, let the user know the new password. */
println(s"Welcome $name, you may now use this service with the password: ${supply.head}")
/* Signal the Access manager that a new user has been welcomed. */
access ! Access.Pair(name,supply.head)
/* Remove the password from the list, so it is not issued again. */
supply = supply.tail
/* In case new passwords arrive, store them in the supply */
case (Register.Passwords(values),source: Noise) => supply = values
/* This cannot be reached, but the compiler is not able to verify. */
case (_,_) => assert(false,"Code should not come here.")
The request for passwords comes in as the first case
in the receive
method
of the actor Noise
. The actor immediately responds with new passwords.
The other request this actor has to handle is the request for some random text.
Note the the sender makes the difference here. At the same time we see that
we can never return a message when we do not know which the type is we
are sending to. Without a type match on sender
, it is not possible to say
sender ! message
for the compiler would not be able verify its legitimacy.
/** This is your ideal white noise char string generator. */
class Noise extends RestrictActor(Noise,"Noise") :
println("Noise Actor Started.")
/* Constructs a string with 'length' random chars. */
def make(length: Int): String = Random.alphanumeric.take(length).mkString
/* Receive method that handles the incoming requests. */
def receive[Sender <: Accept](letter: Letter[Sender], sender: Sender): Unit = (letter,sender) match
/* Return a sequence of size random strings each of the same length. */
case (Noise.Request(_,size,length), source: Register) =>
val result = List.fill(size)(make(length))
source ! Register.Passwords(result)
/* Return a random piece of text of 'size' number of words, each not longer than 'length' chars. */
case (Noise.Request(key,size,length), source: Text) =>
val result = List.fill(size)(make(Random.nextInt(length)+1)).mkString(" ")
source ! Text.Lipsum(key,result)
/* This cannot be reached, but the compiler is not able to verify. */
case (_,_) => assert(false,"Code should not come here.")
In this actor we also need references to other implementations, like in the Register
case.
These two are given as construction parameters.
If we receive name and password in the Text
actor, this is a request for random text. First
we have to verify the credentials for this user. That is done by the Access
actor to which
we pass a verification request. Subsequently the answer comes back from the Access
actor,
handled by the second case. If the user is denied access we tell it and are done. If not,
we must request some random text from the Noise
actor. As the answer comes in, it is
handled by the third case statement, and the result is printed.
class Text(access: Access, noise: Noise) extends RestrictActor(Text,"Text") :
println("Text Actor Started.")
/* Receive method that handles the incoming requests. */
def receive[Sender <: Accept](letter: Letter[Sender], sender: Sender): Unit = (letter,sender) match
/* If we receive this letter from an anonymous source, we interpret it as a request for a new password. */
case (Text.Lipsum(name,password),source: Anonymous) => access ! Access.Pair(name,password)
/* We received a the verdict from the Access actor if we may allow the user. If not, communicate this, otherwise generate new random text. */
case (Text.User(name,allow),source: Access) => if allow then noise ! Noise.Request(name,20,6) else println(s"User $name refused.")
/* If we receive this letter from the Noise actor, we interpret it as random text */
case (Text.Lipsum(name,text),source: Noise) => println(s"Some text for $name: $text")
/* This cannot be reached, but the compiler is not able to verify. */
case (_,_) => assert(false,"Code should not come here.")
Lastly we discuss the Access
actor which only handles two situations, both covered by the
same letter. If the Access.Pair
originates from the Register
, we know this is a
new user that must be given an account, kept in the store
variable. the Access.Pair
originates from the Text
actor, some random text must be returned.
/** This is your access controller. Only existing users are granted access. */
class Access extends RestrictActor(Access,"Access") :
println("Access Actor Started.")
/* Currently registered users. */
var store: Map[String,String] = Map.empty
/* See if a user is present in the store, and if so the password is valid. */
def checkUser(name: String, password: String): Boolean = store.get(name).map(_ == password).getOrElse(false)
/* Receive method that handles the incoming requests. */
def receive[Sender <: Accept](letter: Letter[Sender], sender: Sender): Unit = (letter,sender) match
/* If the pair message comes from the Register actor, we store it as a new/updated user */
case (Access.Pair(name,password),source: Register) => store += name -> password
/* If the pair message comes from the Text Actor we must verify if the user has the correct credentials */
case (Access.Pair(name,password),source: Text) => source ! Text.User(name,checkUser(name,password))
/* This cannot be reached, but the compiler is not able to verify. */
case (_,_) => assert(false,"Code should not come here.")
All elements of this application have now been defined, so we only need some console to make this complete. This could look like:
/* Main entry point for the ChatGRT demo. */
object Main :
/* We must provide a default sender. */
given Actor.Anonymous = Actor.Anonymous
/* Service that generates a random character words. */
private val noise = new Noise
/* Service that keeps a list of the approved accounts. */
private val access = new Access
/* Service that keeps a list of new passwords to be handed out. */
private val register = new Register(access,noise)
/* Service that constructs random texts. */
private val text = new Text(access,noise)
/* Stop all actors */
private def stop(): Unit = List(noise,access,register,text).foreach(_.stop(Actor.Stop.Direct))
/* Analyze the user input (from Console) to see what must be done. Return if we need to continue */
def process(cmd: String): Boolean = cmd.split(" ") match
/* The user request for a new account */
case Array("signup",name) => register ! Register.Request(name); true
/* The user requires some text */
case Array("text",name,pass) => text ! Text.Lipsum(name,pass); true
/* The user needs help */
case Array("help") => println("Type 'signup <name>' or 'text <name> <password>' or 'exit'."); true
/* The user wants to stop */
case Array("exit") => stop(); println("ChatGRT stops."); false
/* In all other cases we do not understand the users wishes, so provide a way to obtain help. */
case _ => println("Unknown command, type 'help'."); true
/* Loop for each command, give the app some room to handle the request. */
def main(args: Array[String]): Unit = while process(StdIn.readLine) do Thread.sleep(100)
This application relies on the use of the user being able to enter data at the command line. This is not possible in Scastie therefore it is best to try this in your own editor, or run the provided demo with the same functionality.
Obviously this code is for illustration. A lot could be improved to get cleaner or more robust code. This is beyond the scope of this documentation, where we favored clarity of the actor system above the former. Two points we would like to make however:
- If you keep state as
var
in the actor class, make sure you always make it private to prohibit leaking. - The most methods the user implements in the classes are protected (like
receive
andstopped
etc). And for a good reason. It is best if they are kept that way when implementing or overriding. And, while you are at it, make themfinal
as well.
For this, and other tips, see the original code on github.