Typesafe Activator

Tweetmap (Java 8)

Tweetmap (Java 8)

retroryan
Source
November 12, 2014
play akka java java8

Starting Template for the Tweetmap Workshop.

How to get "Tweetmap (Java 8)" on your computer

There are several ways to get this template.

Option 1: Choose tweetmap-java8 in the Typesafe Activator UI.

Already have Typesafe Activator (get it here)? Launch the UI then search for tweetmap-java8 in the list of templates.

Option 2: Download the tweetmap-java8 project as a zip archive

If you haven't installed Activator, you can get the code by downloading the template bundle for tweetmap-java8.

  1. Download the Template Bundle for "Tweetmap (Java 8)"
  2. Extract the downloaded zip file to your system
  3. The bundle includes a small bootstrap script that can start Activator. To start Typesafe Activator's UI:

    In your File Explorer, navigate into the directory that the template was extracted to, right-click on the file named "activator.bat", then select "Open", and if prompted with a warning, click to continue:

    Or from a command line:

     C:\Users\typesafe\tweetmap-java8> activator ui 
    This will start Typesafe Activator and open this template in your browser.

Option 3: Create a tweetmap-java8 project from the command line

If you have Typesafe Activator, use its command line mode to create a new project from this template. Type activator new PROJECTNAME tweetmap-java8 on the command line.

Option 4: View the template source

The creator of this template maintains it at https://github.com/retroryan/activator-tweetmap-java8#master.

Option 5: Preview the tutorial below

We've included the text of this template's tutorial below, but it may work better if you view it inside Activator on your computer. Activator tutorials are often designed to be interactive.

Preview the tutorial

Welcome to the Tweetmap Java 8 Going Reactive Tutorial! This starts with a basic Play Framework application and uses it to build a reactive tweet map.

Within the Activator UI you can:

  • Browse & edit the code (select Code. To save a file the keyboard shortcut command-s works.)
  • Add & delete files from the code (select Code and then the plus sign. To delete open the file and click on delete)
  • Open the code in IntelliJ IDEA or Eclipse (select Code, then the gear dropdown)
  • See the compile output (select Compile)
  • Test the application (select Test)
  • Run the application (select Run)

View the App

Once the application has been compiled and the server started, your application can be accessed at: http://localhost:9000

Check in Run to see the server status.

Reactive Request Exercise

Play Java uses Promises to execute asynchronous tasks in the background. The Promise is the handle to a future result. A callback in the form of a Java Lambda is added to the Promise that gets called when the promise completes. Optionally the recover method can be called with a Java Lambda that is called when the callback fails.

The primary way of adding a callback to a Promise is to add a map method that essentially means map the result of the Promise to a new value. Then when the promise completes the new value is returned as the response.

1. Create a new route that will search Twitter by updatingconf/routes with the following route:

GET    /tweets      controllers.Tweets.search(query: String)

2. Update app/controllers/Tweets.java to add a reactive request handler (or controller) for /tweets:



    /**
    * A reactive request is made in Play by returning a Promise of a result.  This makes the request asynchronous
    * since the server doesn't block waiting for the response.  This frees the server thread to handle other requests.
    * The callback to the Promise is added as a map, which maps the results to a new value when the Promise completes.
    * The results of the Promise in this example are mapped to a result (HTTP 200 OK) that gets returned to the client.
    **/
    public static Promise<Result> search(String query) {

        // part 1 - map the results of the fetch tweets to an ok result by
        // calling the ok method on each jsonNode in the returned list of tweets
        return fetchTweets(query)
    }

     /**
     * Fetch the latest tweets and return the Promise of the json results.
     * This fetches the tweets asynchronously and fulfills the promise when the results are returned.
     * The results are first filtered and only returned if the result status was OK.
     * Then the results are mapped (or transformed) to JSON.
     * Finally a recover is added to the Promise to return an error Json if the tweets couldn't be fetched.
     *
     * @param query
     * @return
     */
     public static Promise<JsonNode> fetchTweets(String query) {
        Promise<WSResponse> responsePromise = WS.url("http://twitter-search-proxy.herokuapp.com/search/tweets").setQueryParameter("q", query).get();
        
        //part 2 - map the response to json by calling asJson on each response
        //can also map using method references - WSResponse::asJson

        return responsePromise
            .filter(response -> response.getStatus() == Http.Status.OK)
     }

     /**
     * The error response when the twitter search fails.
     *
     * @param ignored
     * @return
     */
     public static JsonNode errorResponse(Throwable ignored) {
         return Json.newObject().put("error", "Could not fetch the tweets");
     }

3. Test it: http://localhost:9000/tweets?query=typesafe

Reactive Request Solutions


    //part 1
    return fetchTweets(query)
            .map(jsonNode -> ok(jsonNode));

    //part 2 - can also map using method references - WSResponse::asJson
    return responsePromise
            .filter(response -> response.getStatus() == Http.Status.OK)
            .map(response -> response.asJson())
            .recover(Tweets::errorResponse);

AngularJS UI

1. The build.sbt file already has dependencies on AngularJS and Bootstrap:

"org.webjars" % "bootstrap" % "3.0.0",
"org.webjars" % "angularjs" % "1.2.16",
        

2. AngularJS has already been enabled in the main twirl template


<html ng-app="tweetMapApp">
<script src="@routes.Assets.versioned("lib/angularjs/angular.min.js")"></script>

3. Add the following to index.js to fetch the tweets:



app.factory('Twitter', function($http, $timeout) {

    var twitterService = {
        tweets: [],
        query: function (query) {
            $http({method: 'GET', url: '/tweets', params: {query: query}}).
                success(function (data) {
                    twitterService.tweets = data.statuses;
                });
        }
    };

    return twitterService;
});

app.controller('Search', function($scope, $http, $timeout, Twitter) {

    $scope.search = function() {
        Twitter.query($scope.query);
    };

});

app.controller('Tweets', function($scope, $http, $timeout, Twitter) {

    $scope.tweets = [];

    $scope.$watch(
        function() {
            return Twitter.tweets;
        },
        function(tweets) {
            $scope.tweets = tweets;
        }
    );

});

4. Update index.scala.html file:


@(message: String)

@main(message) {

    <div ng-controller="Tweets">
        <ul>
            <li ng-repeat="tweet in tweets">{{tweet.text}}</li>
        </ul>
    </div>
}

5. Run the app, make a query, and verify the tweets show up: http://localhost:9000

Add Websockets Client

Websockets provide a bi-directional, full-duplex communications channels over a single TCP connection. They are created in Play using a normal route. The difference is the controller returns a WebSocket instead of a Result.

1. Update the body of the app.factory section of index.js replacing the var twitterService = ... with :


    var ws = new WebSocket("ws://localhost:9000/ws");

    var twitterService = {
        tweets: [],
        query: function (query) {
            ws.send(JSON.stringify({query: query}));
        }
    };

    ws.onmessage = function(event) {
        $timeout(function() {
            twitterService.tweets = JSON.parse(event.data).statuses;
        });
    };

    return twitterService;

Add Websockets Exercise

1. Add a route for the websocket connection to the routes file:

GET    /ws      controllers.Tweets.ws

2. Add a new controller method to create the Websocket in app/controllers/Tweets.java:


//add this to the import section of the file
import play.mvc.WebSocket;

//add to body of controller
/**
* Create a WebSocket controller that converts the data sent in to JSON.
* This sets up the WebSocket by adding Java Lambdas to the input channel and the on close event.
* The Java Lambda in onMessage will get called whenever a new message comes in through the channel.
* In this example the message is parsed as a json query and then the tweets are fetched based on that query.
* Fetching tweets returns a promise and the callback to that promise (onRedeem) is a Java Lambda that writes
* the results out to the client.
*/

//part 1 - in.onMessage takes a lambda that processes the json from the client.
//  Call the following 2 lines on each json message passed in from the websocket:
//  String query = jsonNode.findPath("query").textValue();
//  fetchTweets(query).onRedeem(.... );
//
//part 2 - fetchTweets(query).onRedeem defines the action to be take when the json is fetched.  
//  write the list of json out to the client by calling:
//  out.write(json)
//  on each json node returned from fetch tweets.
public static WebSocket<JsonNode> ws() {
    return WebSocket.whenReady((in, out) -> {

        in.onMessage(...);

        in.onClose(() -> {
        });
     });
}

To verify tweets are showing up it is useful to use a browser inspector and then look under network for the path ws. Under there look at the frame and verify the requests are being sent. In chrome the network inspector has a bug and the websocket calls are not refreshed unless you tab out and back in.

5. Run the app and verify the tweets show up: http://localhost:9000

Add Websockets Solution


    return WebSocket.whenReady((in, out) -> {
        in.onMessage(jsonNode -> {
            String query = jsonNode.findPath("query").textValue();
            fetchTweets(query).onRedeem(json -> out.write(json));
        });

        in.onClose(() -> {
        });
     });
     

Create an Akka Actor Exercise

In the following exercise use Java Optional to set the query and optionally execute code:

optQuery = Optional.of(query);

optQuery.ifPresent(

1. Create a new UserActor.java file in /app/actors containing the following actor:

2. Fill in the missing match blocks to define the behavior of the actor when it recieves different messages.

Use the same behavior to handle json messages that was previously defined in the websocket handler.



package actors;

import akka.actor.*;
import akka.japi.pf.ReceiveBuilder;
import com.fasterxml.jackson.databind.JsonNode;
import controllers.Tweets;
import scala.concurrent.duration.Duration;

import java.util.Optional;
import java.util.concurrent.TimeUnit;

public class UserActor extends AbstractActor {

    /**
    * The query to search for - empty if a query has not been sent from the user
    */
    public Optional<String> optQuery = Optional.empty();

    /**
    * Creates a new UserActor using these Props.
    *
    * @param out
    * @return
    */
    public static Props props(ActorRef out) {
        return Props.create(UserActor.class, out);
    }

    /**
    * The out ActorRef is used to write back out to the websocket client
    * It is created by Play and set when the UserActor is created.
    */
    private final ActorRef out;

    /**
    * Construct the UserActor and initialize the receive block.
    * The receive block defines this actor handles.
    *
    * @param out
    */
    public UserActor(ActorRef out) {
        this.out = out;

        receive(ReceiveBuilder.
            //A json message is from the client so parse it to get the query and fetch the tweets.
            match(JsonNode.class, ... ).
            //The Update message is sent from the scheduler.  When the Actor recieves the
            //message fetch the tweets only if there is a query from the user.
            match(Update.class, .....  ).
            matchAny(o -> System.out.println("received unknown message")).build()
        );
    }

    /**
    * Fetch the latest tweets for a given query and send the results to
    * the out actor - which in turns sends it back up to the client via a websocket.
    *
    * @param query
    */
    private void runFetchTweets(String query) {
        Tweets.fetchTweets(query).onRedeem(json -> {
            out.tell(json, self());
        });
    }


    /**
    * The Update class is used to send a message to this actor to
    * re-run the query and send the results to the client.
    */
    public static final class Update {
    }

    private final ActorSystem system = getContext().system();

    //This will schedule to send the Update message
    //to this actor after 0ms repeating every 5s.  This will cause this actor to search for new tweets every 5 seconds.
    Cancellable cancellable = system.scheduler().schedule(Duration.Zero(),
            Duration.create(5, TimeUnit.SECONDS), self(), new Update(),
            system.dispatcher(), null);

}

Create an Akka Actor Solution


receive(ReceiveBuilder.
            //A json message is from the client so parse it to get the query and fetch the tweets.
            match(JsonNode.class, jsonNode -> {
                String query = jsonNode.findPath("query").textValue();
                optQuery = Optional.of(query);
                runFetchTweets(query);
            }).
            //The Update message is sent from the scheduler.  When the Actor recieves the
            //message fetch the tweets only if there is a query from the user.
            match(Update.class, update -> optQuery.ifPresent(this::runFetchTweets)).
            matchAny(o -> System.out.println("received unknown message")).build()
        );

Update the WebSocket Exercise

1. Update the WebSocket code to return an Actor in app/controllers/Tweets.java



    //add to import section
    import actors.UserActor;


    //add to body of controller
    public static WebSocket<JsonNode> ws() {
        //create the UserActor using the static UserActor props method.  
        //the withActor method passes an out ActorRef that is wired to the
        //WebSocket out channel.
        //Use this out as the out parameter for UserActor
        return WebSocket.withActor( );
    }


5. Verify that the websocket is still connecting and tweets are refreshing http://localhost:9000. You might not be able to see the browser up because the tweets don't change. So you will need to look at the inspector.

Update the WebSocket Solutions


    return WebSocket.withActor(UserActor::props);

Update the Twitter Search to add Geo-Coding

1. Update app/controllers/Tweets.java to add geo-coding. Replace the existing fetchTweets method with the one below and add the additional methods.


    //Additional imports needed
    import java.util.List;
    import java.util.Random;

    import com.fasterxml.jackson.databind.node.JsonNodeFactory;
    import com.fasterxml.jackson.databind.node.ObjectNode;

    import static java.util.stream.Collectors.toList;
    import static utils.Streams.stream;

    //Replace the existing fetchTweets

    /**
    * Fetch the latest tweets and return the Promise of the json results.
    * This fetches the tweets asynchronously and fulfills the promise when the results are returned.
    * The results are first filtered and only returned if the result status was OK.
    * Then the results are mapped (or transformed) to JSON.
    * Finally a recover is added to the Promise to return an error Json if the tweets couldn't be fetched.
    *
    * The updated fetchTweets transforms the responses as it does the mapping to add the geo-coding.
    *
    * @param query
    * @return
    */
    public static Promise<JsonNode> fetchTweets(String query) {
        Promise<WSResponse> responsePromise = WS.url("http://twitter-search-proxy.herokuapp.com/search/tweets").setQueryParameter("q", query).get();
        //can also map using method references - WSResponse::asJson
        return responsePromise
            .filter(response -> response.getStatus() == Http.Status.OK)
            .map(response -> transformStatusResponses(response.asJson()))
            .recover(Tweets::errorResponse);
    }


    /**
    * Transform the json responses by adding geo coordinates to each tweet.
    * Not sure this is the best way to manipulate the Json.  Mostly an experiment
    * using streams and json based on reactive stocks activator template.
    *
    * @param jsonNode
    */
    private static JsonNode transformStatusResponses(JsonNode jsonNode) {
        //create a stream view of the jsonNode iterator
        List<JsonNode> newJsonList = stream(jsonNode.findPath("statuses"))
            //map the stream of json to update the values to have the geo-info
            .map(json -> setCoordinates((ObjectNode) json))
            .collect(toList());

        ObjectNode objectNode = JsonNodeFactory.instance.objectNode();
        objectNode.putArray("statuses").addAll(newJsonList);

        return objectNode;
    }

    /**
    * Most tweets don't actually have their geo-location set so just randomly set the latitude and longitude.
    * And sadly there is a bug in the randomizer where the tweets tend to locate themselves near the top of the window.
    *
    * @param nextStatus
    */
    private static ObjectNode setCoordinates(ObjectNode nextStatus) {
        nextStatus.putArray("coordinates").add(randomLat()).add(randomLon());
        return nextStatus;
    }

    private static Random rand = new java.util.Random();

    private static double randomLat() {
        return (rand.nextDouble() * 180) - 90;
    }

    private static double randomLon() {
        return (rand.nextDouble() * 360) - 180;
    }


Add the Tweet Map

1. The webjar dependency on leaflets has already been added to build.sbt

2. The Leaflet CSS and JS have already been added to main.scala.html file:


    <link rel='stylesheet' href='@routes.Assets.versioned("lib/leaflet/leaflet.css")'>
    <script type='text/javascript' src='@routes.Assets.versioned("lib/leaflet/leaflet.js")'></script>
    <script type='text/javascript' src='@routes.Assets.versioned("lib/angular-leaflet-directive/angular-leaflet-directive.min.js")'></script>

3. Above the <ul> in index.scala.html add a map:

<leaflet width="100%" height="500px" markers="markers"></leaflet>

4. Update the first line of index.js with:


    var app = angular.module('tweetMapApp', ["leaflet-directive"]);

5. Update the body of the app.controller('Tweets' ... section of the index.js file with the following:


    $scope.tweets = [];
    $scope.markers = [];

    $scope.$watch(
        function() {
            return Twitter.tweets;
        },
        function(tweets) {
            $scope.tweets = tweets;

            $scope.markers = tweets.map(function(tweet) {
                return {
                    lng: tweet.coordinates[0],
                    lat: tweet.coordinates[1],
                    message: tweet.text,
                    focus: true
                }
            });
        }
    );

8. Go to http://localhost:9000 to see the TweetMap!

comments powered by Disqus