Introduction
In early 2018, google announced a change in the pricing of their maps services, finally implementing it half a year later. The new prices would have been prohibitively expensive for many tech companies (think thousands of U.S. dollars a month) and many scrambled to find alternatives. Along with everyone else who was affected, we had to:
- Find alternative service providers for each google maps service we used
- Compare pricing, quality, and licensing (Many providers, while good, forbade the combination of their service with others. Even more maddening, few actually support the Philippines.)
- Decide, and then implement across all affected platforms.
The problem is, of course, that step 3 is a lot of engineering effort, and steps 1 and 2 take time. When customer satisfaction and operation costs are on the line, one does not simply say:
"Nah, fam. I already implemented the thing. We're stuck with this suboptimal service because it would take too much effort to switch."
We can't wait 5 months for a final decision either.
The solution then, is to start early and build out infrastructure to reasonably support every alternative.
We use two major features: the map and the geocoding service. We'll be focusing on the map because it's "read only". Information only flows from the app state into the map, and not the other way around. As for forward and reverse geocoding, that's for the next post in the series.
We'll be making a very simple toy that switches between two mapping services during runtime:
Protocols and Classes
Modern mapping libraries have roughly the same features. Beyond displaying the map itself, one can expect to place markers or pins, draw lines and shapes, and control what part of the map is shown. While these features are relatively consistent, their APIs vary greatly across alternatives.
Javascript's prototypes and classes may help bring sanity to this, but Clojure (and therefore ClojureScript) offers a slightly different approach with protocols. Instead of dealing with a hierarchy, you deal with characteristics. I'll give a quick example to illustrate the differences.
Classes
The following is a sample class hierarchy involving pigeons, penguins, bats, and whales.
- Animals
- Birds
- Flying Birds
- Pigeons
- Aquatic Birds
- Penguins
- Flying Birds
- Mammals
- Flying Mammals
- Bats
- Aquatic Mammals
- Whales
- Flying Mammals
- Birds
Because a class hierarchy is a tree, the sharing of attributes and methods outside of the parent-child relationship is, depending on the language, anywhere between complicated and impossible.
Protocols
In contrast, the same set of animals can be described in this way:
- Pigeons (Flying, Avian)
- Penguins (Aquatic, Avian)
- Bats (Flying, Mammalian)
- Whales (Aquatic, Mammalian)
While the Flying protocol shared by pigeons and bats may declare a set of methods like fly
, land
, and maneuver
, implementation will be specific to pigeons and bats. (of course! they work differently.)
Protocols lend themselves very well to composition, but sometimes it's harder to see structure. As the saying goes, use the right tool for the right job.
Kidding aside, the great thing about Clojure and ClojureScript being hosted languages is that you kind of get the best of both worlds. If the situation calls for it, you can always dig down into interop and use the respective class systems of Java and JavaScript.
Protocol Declaration
The first step is in declaring the protocols. There are many ways of organizing protocols and namespaces, but I like separating protocol declaration from implementation. This is how I've structured the project below.
Please read the inline documentation and comments–they're part of this article!
(ns sanity.protocols)
(defprotocol MapLike
"Things-that-are-like-maps."
(get-dom-node [this]
"Returns the dom-node that this map is attached to.")
(add-marker! [this marker-config]
"For now, marker-config only has one relevant key: `:position`")
(add-polyline! [this polyline-config]
"Three relevant keys: `:path`, `:stroke-color`, `:stroke-weight`"))
(defprotocol MapEntity
"For anything that will appear on a map."
(destroy! [this]
"Everything can be destroyed!")
(set-opacity! [this opacity]
"Most map providers allow you to control the opacity of map entities.
This method can be moved to more specific protocols, but it can stay here."))
(defprotocol MapSingleEntity
"Entities that exist as single points on a map, as opposed to lines and polygons."
(set-position! [this position]))
Protocol Implementation
And now we can implement these protocols as appropriate. Note that in the following section, I will be using extend-type
, which modifies an existing type (like google.maps.Marker
) with additional methods that conform to the attached protocols. extend-type
is not the only way to use protocols: there's defrecord
and reify
, and while they're safer to use than extend-type
, extend-type
makes a lot of things more convenient for us. Instead of having to store the native javascript object as a field in a ClojureScript data structure, we can just use the object itself.
Google Maps
Let's declare a separate namespace
(ns sanity.google
(:require [sanity.protocols :as sp]))
And then extend the marker type. A marker is a generic map entity, but it's also a single map entity representing a single point on the map.
We are returning nil in destroy!
because it should no longer exist.
(extend-type js/google.maps.Marker
sp/MapEntity
(destroy! [this]
(.setMap this nil)
nil)
(set-opacity! [this opacity]
(.setOpacity this opacity)
this)
sp/MapSingleEntity
(set-position! [this position]
(.setPosition this position)
this))
In contrast, the polyline does not represent a single point, so set-position!
makes no sense. So, we only want to extend polyline with just the MapEntry
protocol.
(extend-type js/google.maps.Polyline
sp/MapEntity
(destroy! [this]
(.setMap this nil)
nil)
(set-opacity! [this opacity]
(.setOptions this #js {:strokeOpacity opacity})
this))
Probably the most straightforward implementation here is get-dom-node
, effectively an alias for getDiv
. We go through this trouble because we want a consistent API for all maps we could possibly want to use.
(extend-type js/google.maps.Map
sp/MapLike
(get-dom-node [this]
(.getDiv this))
(add-marker! [this marker-config]
(js/google.maps.Marker. (clj->js (assoc marker-config
:map this))))
(add-polyline! [this polyline-config]
(js/google.maps.Polyline. (clj->js (assoc polyline-config
:map this)))))
Finally, we explicitly define a new-google-map
function to instantiate a… new google map. We could have named this as simply new-map
to allow for consistency of this "constructor" function across the other services, but in this case it's important to be very clear about what it is you're constructing.
(defn new-google-map [map-config]
(let [{:keys [dom-node center zoom]} map-config]
(js/google.maps.Map. dom-node
#js {:center (clj->js center)
:zoom zoom})))
Mapbox
Now let's implement the same protocol for mapbox. Notice that in our namespace declaration, mapbox-gl
is in the require statement. Unlike google maps, mapbox exists as an npm library. Unfortunately, it's huge–it takes up about 23% the size of final sakay webapp artifact, (in contrast, ClojureScript takes up 17%, and the sakay-specific code is another 17%) but there's not much we can do about that.
(ns sanity.mapbox
(:require [sanity.protocols :as sp]
["mapbox-gl" :as mapbox]))
Mapbox markers are interesting. They're actually dom elements that are positioned relative to the map. So, to set the marker's opacity, we can just apply css styling. I'll leave the implementation of this method as an exercise for the reader.
(extend-type mapbox/Marker
sp/MapEntity
(destroy! [this]
(.remove this)
nil)
(set-opacity! [this opacity]
this)
sp/MapSingleEntity
(set-position! [this {:keys [lat lng]}]
;; Unfortunately, mapbox and google maps do not agree
;; on the representation of map positions.
(.setLngLat this #js {:lon lng :lat lat})
this))
Mapbox doesn't have polylines as a first-class entity, so we have to make a record that implements the appropriate protocols. You can think of records as clojure maps that have methods associated with them.
(defrecord MapboxPolyline [street-map id]
sp/MapEntity
(destroy! [this]
(when (.getLayer street-map id)
(.removeLayer street-map id))
(when (.getSource street-map id)
(.removeSource street-map id)))
(set-opacity! [this opacity]
(.setPaintProperty street-map id "line-opacity" opacity)))
And since polylines aren't a first-class entity, "constructing" it is a little different. Also notice the trivially simple implementation for get-dom-node
.
(extend-type mapbox/Map
sp/MapLike
(get-dom-node [this]
(.getContainer this))
(add-marker! [this {:keys [position] :as marker-config}]
(-> (mapbox/Marker. #js {})
(sp/set-position! position)
(.addTo this)))
(add-polyline! [this {path :path
stroke-color :strokeColor
stroke-weight :strokeWeight
:as polyline-config}]
;; This bit is long because mapbox treats polylines differently from google maps.
;; Remember what I said about vastly different APIs? :P
(let [polyline-id (random-uuid)
line-source {:type "geojson"
:data {:type "Feature"
:geometry {:type "LineString"
:properties {}
:coordinates (map (fn [{:keys [lat lng]}]
[lng lat])
path)}}}
line-layer (clj->js
{:id polyline-id
:type "line"
:layout {:line-join "round"
:line-cap "round"}
:paint {:line-color stroke-color
:line-width stroke-weight}
:source line-source})]
(.addLayer this line-layer)
(map->MapboxPolyline {:street-map this
:id polyline-id}))))
(defn new-mapbox-map [{:keys [dom-node center zoom style]}]
(let [{:keys [lat lng]} center]
(mapbox/Map. #js {:container dom-node
:center #js [lng lat]
:zoom zoom
:style "https://tiles.stadiamaps.com/styles/alidade_smooth.json"})))
Now that our implementation is complete for both providers, we can use them.
(ns sanity.core
(:require [sanity.protocols :as sp]
[sanity.google]
[sanity.mapbox]))
(def use-google "So we can switch between google and mapbox." (atom false))
(def app-map "Stores the map object. Can be either google or mapbox." (atom nil))
(defn init-map []
(let [map-config {:dom-node (js/document.getElementById "map")
:zoom 12
:center {:lat 14.6091
:lng 121.0223}}]
(if @use-google
(sanity.google/new-google-map map-config)
(sanity.mapbox/new-mapbox-map map-config))))
(defn setup []
;; Replace the `app-map` atom with the value of a newly initialized map.
(reset! app-map (init-map))
;; Notice that it doesn't care if the map is google or mapbox.
;; The correct implementation will be used regardless.
(sp/add-marker! @app-map {:position {:lat 14.6091
:lng 121.0223}}))
(defn ^:export switch-provider []
(swap! use-google not)
(setup))
(defn init []
(let [switch-button (js/document.getElementById "switch-button")]
(.addEventListener switch-button "click" switch-provider))
(setup))
At the time of writing, there is a #wontfix
memory leak in google maps making it difficult to properly destroy a map instance. Having said this, I need to point out a few important things regarding this leak, and the exercise we just did.
- The google map we're instantiating here is very bare, and the memory leak is unlikely to affect this demo app much. For an actually useful webapp though, this leak will be non-trivial.
- You probably shouldn't even be switching between different map providers during runtime. Just comment things out, and refresh the browser. Our runtime switch is for illustrative purposes only.
- If things are really that bad that you need to support service switching right up to deployment, you can come up with compiler flags via
goog-define
that are basically variables that can be initialized based on build flags.
Final Remarks
Protocols are a powerful tool for creating and managing abstractions or interfaces to external services. They are not limited to our example above. Protocols can be used for database connections, display rendering, and more.
In the next part of this series, I'll show you how we dealt with different geocoding providers using a webapp framework called fulcro.
Finally, here is the working sample again, for your reference: