This chapter points out some of the useful and fun features of Dart
that we used to build Dart Chat, a client-server app. If you’d like
step-by-step instructions on building Dart Chat, you might be interested in
our code lab.
The chat server and client are simple. The chat server is an HTTP
server that provides a WebSocket. The chat client uses that WebSocket for
a bi-directional communication channel with the server. The client sends
chat messages to the server over the WebSocket, and the server relays
those messages to all other connected clients.
As Figure 5.2, “Chat clients connect to a web socket created by the chat
server” shows, the server
starts things off by listening for requests to ws://127.0.0.1:1337/ws. Chat clients then
connect to that URL.
The real communication between client and server happens when the
user enters a message. As Figure 5.3, “A chat client uses the server to send a message to other chat
clients” shows,
the chat client sends a JSON-encoded version of the message to the server.
The server then forwards the message to every client except the one that
sent it.
The chat server implements an HTTP server to listen for WebSocket
requests. The HTTP server can also serve static files from the client
directory—for example,
http://127.0.0.1:1337/chat-log.txt shows the file
that’s at client/chat-log.txt.
The client code is split between HTML (page structure), CSS (page
look), and JavaScript (logic and behavior). That’s typical of web
clients.
The twist is that this client’s JavaScript code is produced from
Dart code, thanks to the dart2js compiler. Any modern browser can run this
JavaScript code. Dartium (and any other browsers that support Dart) can
run either the JavaScript code or the original Dart code.
The main elements in the client UI are two text fields (with the IDs
“chat-username” and “chat-message”) and a status area (ID:
“chat-display”):
<!-- In client/index.html: -->
<textarea id="chat-display" rows="10" disabled></textarea>
...
<input id="chat-username" name="chat-username" type="text">
...
<input id="chat-message" name="chat-message" type="text" disabled
value="enter message...">Near the bottom of client/index.html, a couple
of <script> tags tell the browser to execute the client’s Dart or
JavaScript code:
<script type="application/dart" src="chat-client.dart"></script>
<script src="packages/browser/dart.js"></script>
The first line works in browsers that have an embedded Dart VM and
so can execute Dart code; currently, only Dartium qualifies. The second
line is important for every other browser. It executes
dart.js, which is a standard script that converts all
Dart <script> tags to use foo.dart.js instead
of foo.dart, with the assumption that
foo.dart.js is a JavaScript version of
foo.dart. For non-Dartium browsers,
dart.js changes the first <script> tag to the
following:
<!-- Inserted by dart.js for non-Dartium browsers -->
<script src="chat-client.dart.js"></script>
The script contents run when the browser has loaded the HTML and
constructed its DOM (document object model).
You can get dart.js with the browser package from
pub. See the section called “dart2js: The Dart-to-JavaScript Compiler” for more
information about compiling Dart code into its JavaScript
equivalent.
Dart code (client/chat-client.dart) provides
the client’s logic, using the DOM to interact with UI elements. For
example, the client’s Dart code uses the DOM to find the text area where
the client displays messages.
The client app uses dart:html’s top-level
query() method to get the client’s UI elements from
the DOM:
// In client/chat-client.dart:
import 'dart:html';
//...
TextAreaElement chatElem = query('#chat-display');
InputElement usernameElem = query('#chat-username');
InputElement messageElem = query('#chat-message');The query() method uses a selector string that
identifies an element in the DOM. See the section called “Finding elements” for more about selectors.
Rather than always dealing with DOM APIs, the chat client wraps
the elements in Dart objects:
new ChatWindow(chatElem);
usernameInput = new UsernameInput(usernameElem);
messageInput = new MessageInput(messageElem);
ChatWindow, UsernameInput, and MessageInput are custom classes
that extend another custom class called View. These Views effectively
separate the DOM manipulation from the application logic.
Because Dart has real classes and inheritance, it’s simple to
express the relationship that ChatWindow is-a View. Here’s the complete
code for UsernameInput:
class UsernameInput extends View<InputElement> {
UsernameInput(InputElement elem) : super(elem);
bind() { // Called by the View constructor.
elem.onChange.listen((e) => _onUsernameChange());
}
_onUsernameChange() {
if (!elem.value.isEmpty) {
messageInput.enable();
} else {
messageInput.disable();
}
}
String get username => elem.value;
}To get the string that’s in the chat-username field, the client
app uses the username getter of a
UsernameInput object. For example:
chatWindow.displayMessage(message, usernameInput.username);
Notice how the code uses generics (View<InputElement>) to
specify what kind of element the View class can encapsulate. In the
preceding example, the UsernameInput wraps an InputElement.
Expressing this gives tools information that they can use to identify
bugs or improve code completion.
Wrapping elements is a technique you can use as you develop a
simple app that might evolve into a larger app. As the app grows, you
might change it to use Web UI, a library
for declarative widgets and dynamic, data-driven views.
The bind() method sets up the event handlers,
which bind events from the DOM to logic in the Dart objects. For
example, in UsernameInput, the _onUsernameChange()
method is called any time the text in the input element changes.
To display messages in the chat window, the ChatWindow class adds
the message to the text node of the text area:
class ChatWindow extends View<TextAreaElement> {
ChatWindow(TextAreaElement elem) : super(elem);
displayMessage(String msg, String from) {
_display('$from: $msg\n');
}
displayNotice(String notice) {
_display('[system]: $notice\n');
}
_display(String str) {
elem.addText(str);
}
}In both examples, the View objects expose an application-specific
API—for example, displayMessage() or
_onUsernameChange()—and encapsulate the manipulation
of DOM elements.
Encoding and Decoding Messages
The dart:json library encodes and decodes JSON-formatted messages.
JSON is an easy way to provide string message data to WebSockets. Using
JSON also gives a bit of structure to the messages and leaves the door
open to creating more detailed messages in the future.
The stringify() method converts a Dart object
to a JSON-encoded string, and the parse() method
converts a JSON string back into a Dart object. Here’s the JSON-related
code from the chat client:
import 'dart:json' as json;
var encoded = json.stringify({'f': from, 'm': message});
Map message = json.parse(encodedMessage);See the section called “dart:json - Encoding and Decoding Objects” for more information.
Communicating with WebSockets
The custom class ChatConnection takes care of the chat client’s
WebSocket communication. First it connects to the WebSocket by calling
the WebSocket constructor with the argument 'ws://127.0.0.1:1337/ws'. Then ChatConnection
adds event handlers for open, close, error, and message events. For
example, here’s the code that responds to message events:
webSocket.onMessage.listen((MessageEvent e) {
print('received message ${e.data}');
_receivedEncodedMessage(e.data);
});The _receivedEncodedMessage() method just
parses the JSON data and displays it in the status area:
_receivedEncodedMessage(String encodedMessage) {
Map message = json.parse(encodedMessage);
if (message['f'] != null) {
chatWindow.displayMessage(message['m'], message['f']);
}
}To send a message on the WebSocket connection,
_sendEncodedMessage() ensures the WebSocket
connection is ready and then sends the JSON-encoded message:
// In the ChatConnection class:
send(String from, String message) {
var encoded = json.stringify({'f': from, 'm': message});
_sendEncodedMessage(encoded);
}
_sendEncodedMessage(String encodedMessage) {
if (webSocket != null && webSocket.readyState == WebSocket.OPEN) {
webSocket.send(encodedMessage);
} else {
print('WebSocket not connected, message $encodedMessage not sent');
}
}In the event of a connection problem, the client code attempts to
reconnect to the WebSocket server. The following code takes advantage of
Dart’s nested functions, nesting the
scheduleReconnect() function inside of
_init(). Dart’s lexical scoping ensures that
scheduleReconnect() can see variables from
_init().
_init([int retrySeconds = 2]) {
bool encounteredError = false;
chatWindow.displayNotice('Connecting to Web socket');
webSocket = new WebSocket(url);
scheduleReconnect() {
chatWindow.displayNotice('socket closed, retrying in $retrySeconds seconds');
if (!encounteredError) {
new Timer(new Duration(seconds:retrySeconds),
() => _init(retrySeconds*2));
}
encounteredError = true;
}
//...
webSocket.onClose.listen((e) => scheduleReconnect());
webSocket.onError.listen((e) => scheduleReconnect());The reconnect logic uses new Timer() to
schedule a retry using an exponential backoff algorithm.
The chat-server.dart file contains most of the code used in the chat
server. It is responsible for serving static files and managing WebSocket
connections. The chat server also logs the chat messages to a file.
The chat server uses dart:io’s HttpServer to implement a web
server. The default request handler is configured to serve static files
from a specific directory on the file system.
runServer(String basePath, int port) {
ChatHandler chatHandler = new ChatHandler(basePath);
StaticFileHandler fileHandler = new StaticFileHandler(basePath);
HttpServer.bind('127.0.0.1', port)
.then((HttpServer server) {
print('listening for connections on $port');
var sc = new StreamController();
sc.stream.transform(new WebSocketTransformer()).listen(chatHandler.onConnection);
server.listen((HttpRequest request) {
if (request.uri.path == '/ws') {
sc.add(request);
} else {
fileHandler.onRequest(request);
}
});
},
onError: (error) => print("Error starting HTTP server: $error"));
}The StaticFileHandler first gets the file contents using File and
Stream. It then sends those contents using the HttpResponse object from
the request.
Because I/O can cause delays, due to variable network or disk
bandwidth conditions, the chat server uses asynchronous I/O to handle
HTTP requests while still being responsive to other requests. Each I/O
request returns a Future, allowing the server to continue executing
without waiting for the I/O to complete.
For example, in the following snippet the
exists() method returns a Future. When the Future
completes (with a value of true if the file exists, or false if it
doesn’t), the function specified to then()
executes.
// Respond to HTTP requests for static files.
onRequest(HttpRequest request) {
final String path =
request.uri.path == '/' ? '/index.html' : request.uri.path;
final File file = new File('${basePath}${path}');
file.exists().then((bool found) {
if (found) {
file.fullPath().then((String fullPath) {
if (!fullPath.startsWith(basePath)) {
_send404(request.response);
} else {
file.openRead().pipe(request.response)
.catchError((e) => print(e));
}
});
} else {
_send404(request.response);
}
});
}See the section called “dart:async - Asynchronous Programming” for more
information about using Future, and the section called “Files and Directories” for details on file and directory I/O.
Managing WebSocket Connections
In addition to serving static files, the chat server manages
WebSocket connections, routing chat messages between clients. The
dart:io WebSocketTransformer class accepts HTTP connections, converts
them into WebSocket connections, and then passes them to
ChatHandler.
runServer(String basePath, int port) {
//...
var sc = new StreamController();
sc.stream.transform(new WebSocketTransformer())
.listen(chatHandler.onConnection);
}ChatHandler is a custom class that takes care of all WebSocket
communication for the chat server. Here is its implementation:
class ChatHandler {
Set<WebSocketConnection> connections;
//...
onConnection(WebSocket conn) {
void onMessage(message) {
print('new ws msg: $message');
webSocketConnections.forEach((connection) {
if (conn != connection) {
print('queued msg to be sent');
queue(() => connection.send(message));
}
});
time('send to isolate', () => log.log(message));
}
print('new ws conn');
webSocketConnections.add(conn);
conn.listen(onMessage,
onDone: () => webSocketConnections.remove(conn),
onError: (e) => webSocketConnections.remove(conn)
);
}
}When a client connects, the server adds the client’s WebSocket
connection to a collection. When the client disconnects (either through
an error or on purpose), the server removes that client’s connection
from the collection. When a new message arrives, the server sends the
message to all connected clients except the original source.
Logging Messages to a File
The chat server logs data to a file, client/chat-log.txt, using a
custom library implemented in file-logger.dart. This library uses an
isolate to handle file I/O without
tying up the root isolate. Here’s the code that creates and starts this
isolate:
SendPort _loggingPort = spawnFunction(startLogging);
The value returned by dart:isolate’s
spawnFunction() is a SendPort object. Because
isolates share no data, messages sent to ports are the only way for the
root isolate to communicate with the spawned isolate.
The argument to spawnFunction() points to the
startLogging() function, which implements the logging
isolate. The logic for the logging isolate is simple: the first message
specifies the log file location, and subsequent messages provide data to
write to the log file.
startLogging() {
print('started logger');
File logFile;
IOSink out;
port.receive((msg, replyTo) {
if (logFile == null) {
print('Opening file $msg');
logFile = new File(msg);
out = logFile.openWrite(mode: FileMode.APPEND);
} else {
time('write to file', () {
out.write('${new DateTime.now()} : $msg\n');
});
}
});
}In the preceding code, the port property used by
startLogging() refers to a ReceivePort provided by
dart:isolate. The port is how this isolate gets data from the root
isolate. If this isolate needed to send messages back to the root
isolate, it could use the replyTo argument (a SendPort) to do so.
Recall that in the root isolate, the _loggingPort variable holds a
SendPort that the root isolate uses to send messages to the logging
isolate. Every time the chat server calls the log()
method, the root isolate sends the log data:
void log(String message) {
_loggingPort.send(message);
}See the section called “dart:isolate - Concurrency with Isolates” for more
information about using isolates.