In this document:
- Introduction to serverless microservices
Microservices are independent modules that are small enough to take care of a single action, and can be independently built, verified, deployed, and monitored. Applications that are based on microservices combine these independent modules into a highly decoupled collection, providing the following additional benefits over traditional "monolithic" applications:
- Autonomous scalability: The independent microservices modules and their related services can be individually and automatically scaled based on their respective demands without impacting the application's overall performance. The ability to independently scale removes the need to scale the entire app up or down, potentially saving costs and reducing downtime.
- Isolated points of failure: Each of the services can be managed independently, isolating potential problem areas to individual services, and replacing or retiring services when deprecated or unused without affecting the overall structure and functionality.
- Pick what's best: Microservices solutions let development teams use the best deployment approach, language, platform and programming model for each service, providing flexibility in choosing technologies and tools.
- Faster value delivery: Microservices increase agility putting new features in production and adding business value to solutions, as the deployment of small and independent modules requires much less time and several teams can be working on different services at the same time, reducing development time and simplifying deployment.
Relecloud chose to capitalize on these benefits of a microservices architecture to help them build their Rideshare application in a way that enables their teams of developers to independently focus on portions of the solution based on their strengths without too many dependencies on what other teams are working on.
Read more information on the benefits of building microservice-based applications.
The term "serverless" can be confusing to the uninitiated. It can be read as "no servers", in the way that "hopeless" means "no hope", or "cloudless sky" means "no clouds". In the case of serverless architecture, it simply means you focus "less on servers", and more on the functionality and features of your solution. This is because serverless abstracts away servers so you do not need to worry about server configuration, scaling of underlying resources is usually automatically handled for you based on load, number of messages, and other heuristics, and your deployments are done at the service and application-level rather than at the infrastructure-level. The end result is increased productivity, ease of development, simplified interoperability with other services through event-driven triggers and preconfigured service hooks, and increased choice of languages and tooling for the solution as a whole through mixing and matching various serverless components.
Relecloud recognized great value in combining the flexibility and service isolation of a microservices architecture, with the consumption-based (pay based on usage, like a utility bill), independent distributed nature of serverless technologies on Azure to rapidly build and grow their new Rideshare application. The combination of these architectures, deemed "serverless microservices", is ideal for helping them reach their goals for this application:
- Rapid development by their teams of developers who can focus on specific components of the solution without the usual dependency-riddled challenges of developing monolithic applications
- Global distribution of their architecture, with automatic scaling of individual components based on demand
- Consumption-based billing that saves them money during off-peak hours
- The ability to deploy updates to portions of the solution without affecting the application as a whole
The following sections detail Relecloud's architectural decisions, based on these goals. You can also read more about serverless on Azure, for more information on the serverless components used in this solution.
Relecloud decided to use the following macro architecture in their RideShare solution:
The architecture major building blocks are:
Component | Technology | Description |
---|---|---|
RideShare Web App | Vue.js SPA | A multi-purpose, single-page application web app that allows users to sign up and sign in against a B2C Active Directory instance. Users have different levels and permissions. For example, passenger users can request rides and receive real-time notifications of ride status. Executive users, on the other hand, can view top-level reports that reveal rides and system performance |
API Manager | Azure API Manager | An API gateway that acts as a front-end to the solution APIs. Among many other benefits, the API management service provides RideShare APIs with security verification, usage telemetry, documentation and rate limiting. |
RideShare APIs | C# Azure Functions | Three Function Apps are deployed to serve RideShare's APIs: Drivers, Trips and Passengers. These APIs are exposed to the Web App applications via the API manager and provide CRUD operations for each of RideShare entities |
Durable Orchestrators | C# Durable Functions | Trip Manager, Monitor and Demo orchestrators are deployed to manage the trip and provide real-time status updates. The orchestrators are launched for the duration of the trip and they perform management and monitoring functions as will be explained in more details later. In essence, these orchestrators make up the heart of the solution. |
Event Emitter | Event Grid Topic | A custom topic used to externalize trips as they go through the different stages. |
Event Subscribers | Functions & Logic Apps | Several Event Grid topic subscribers listen to the Event Grid topic events to provide multi-process capability of an externalized trip |
The following are the Event Grid Subscribers:
Subscriber | Technology | Description |
---|---|---|
Notification | Logic App | A trip processor to notify admins i.e. emails or SMS as the trip passes through the different stages. |
SignalR | C# Azure Function | A trip processor to update passengers (via browsers or mobile apps) in real-time about trip status. |
Power BI | C# Azure Function | A trip processor to insert the trip into an SQL Database and possibly into a Power BI dataset (via APIs). |
Archiver | Node.js Azure Function | A trip processor to archive the trip into Azure Cosmos DB |
Relecloud decided to use the following criteria to determine when a certain piece of functionality is to be considered a Microservice:
- The functionality must scale or be deployed independently from other parts.
- The functionality must be written in a separate language/technology like Node.js in case there is some certain expertise that is only available in that specific technology.
- The functionality must be isolated by a clean boundary
Given the above principles, the following are identified as Microservices:
Microservice | Technology | Reason |
---|---|---|
Drivers APIs | C# | The Drivers API is code and deployment independent isolated in a Function App. |
Trips APIs | C# | The Trips API is code and deployment independent isolated in a Function App. |
Passengers APIs | C# | The Passengers API is code and deployment independent isolated in a Function App. |
Durable Orchestrators | C# | The Trip Manager , Monitor and Demo i.e. Orchestrators are independent as they provide the heart of the solution. They need to scale and deploy independently. |
Event Grid Notification Handler | Logic App | The Logic App handler adds value to the overall solution but works independently. |
Event Grid SignalR Handler | C# | The SignalR handler adds value to the overall solution but works independently. |
Event Grid Power BI Handler | C# | The Power BI handler adds value to the overall solution but works independently. |
Event Grid Archiver | Node.js | The Node.js Archiver handler adds value to the overall solution but works independently. |
Please note that, due to code layout, some Microservices might be a Function within a Function App. Examples of this are the Event Grid SignalR Handler
and Event Grid Power BI Handler
Microservices. They are both part of the Trips
Function App.
The following is a detailed diagram showing how the different architecture components communicate and the Azure services they use:
The sample uses a front-end SPA Web App to allow passengers to login in, manage trips and see previous trips. The SPA uses an API manager to access the solution front-end APIs.
When a passenger decides to request a trip, a request containing the passenger information and the trip source and destination locations is posted to the Trips
Microservice via is exposed front-end API:
{
"passenger": {
"code": "[email protected]",
"firstName": "Joe",
"lastName": "James",
"mobileNumber": "+13105551212",
"email": "[email protected]"
},
"source": {
"latitude": -31.7654,
"longitude": 54.9011
},
"destination": {
"latitude": -32.5625,
"longitude": 60.6276
},
"type": 1
}
The Trips
Microservice stores the trip in Azure Cosmos DB, enqueues the Trip
item to the Orchestrators
Microservice and returns the newly created Trip
information such as code and other properties. Optionally the Orchestrators
Microservice can also be triggered via its internally-exposed API.
For more information on the operation of the durable orchestrators, please refer to the Durable Orchestrators section below.
The Orchestrators
Microservice instantiates a Durable Trip Manager
to manage the trip until it completes. The Trip Manager
performs the following tasks:
- Notify available drivers that a new trip is requested. Available drivers are identified as drivers who are within x mile radius from the trip source location and that they are currently not servicing other passengers. The
Trip Manager
sendsDrivers notified
state change event to the Event Grid. - Wait for either a timeout timer to occur or an external event to signal that a driver accepted the trip:
- If a timeout occurs, the
Trip Manager
aborts the trip indicating that no driver is interested in the requested trip. TheTrip Manager
sendsTrip aborted
state change event to the Event Grid. - If an external signal is received, the
Trip Manager
proceeds with the orchestration. It is worth mentioning that when a driver accepts a trip, he/she posts a request (via the SPA or more realistically a Mobile App) to theTrips
API indicating that a driver is willing to accept the trip i.e.api/trips/{code}/drivers/{drivercode}
. TheTrips
Microservice then calls upon theOrchestrators
Microservice API to trigger the external event.
- If a timeout occurs, the
- Assign the driver (that accepted the trip) to the
Trip
item. TheTrip Manager
sendsDrivers picked
state change event to the Event Grid. - Enqueue a message to the
Trip Monitor
queue.
When the Trip Monitor
queue is triggered, the Orchestrators
Microservice instantiates a Durable Trip Monitor
to monitor the trip progress and report state changes.
- The
Trip Monitor
starts a timer to be triggered every x seconds to check whether the trip is completed or not. If completed, it indicates that the trip is completed and sendsTrip completed
state change event to the Event Grid. Otherwise, it sendsTrip running
state change event to the Event Grid. - The
Trip Monitor
does not let trips run forever! It aborts the trip if it does not complete within configurable amount of time.
When events are sent to the Event Grid Topic
, they trigger the different handler Microservices to further process the trip:
- Notification Microservice
- SignalR Handler Microservice
- Power BI Handler Microservice
- Archiver Handler Microservice
Below is a detailed description of the components that make up the architecture.
There are many benefits to using an API manager. In the case the Rideshare solution, there are really four major benefits:
- Security: the API manager layer verifies the incoming requests' JWT token against the B2C Authority URL. This is accomplished via an inbound policy that intercepts each call:
<validate-jwt header-name="Authorization" failed-validation-httpcode="401" failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
<openid-config url="<--your_own_authorization_url-->" />
<audiences>
<audience><-- your_own_app_id --></audience>
</audiences>
</validate-jwt>
Please note that Relecloud considered using Azure Functions Filters to intercept HTTP calls and validate the JWT token in code instead of relying on an APIM layer. This has the advantage of applying security validation regardless of whether an APIM is used or not.
Here is the Attribute
that was created:
public class B2cValidationAttribute : FunctionInvocationFilterAttribute
{
public override Task OnExecutingAsync(FunctionExecutingContext executingContext, CancellationToken cancellationToken)
{
var httpRequest = executingContext.Arguments.First().Value as HttpRequest;
if (httpRequest == null)
throw new ValidationException("Http Request is not the first argument!");
var validationService = ServiceFactory.GetTokenValidationService();
if (validationService.AuthEnabled)
{
//TODO: Not the best way to do this!!
var user = validationService.AuthenticateRequest(httpRequest).Result;
if (user == null)
{
//httpRequest.HttpContext.Response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
//return Task.FromResult(0);
throw new ValidationException("Unauthorized!");
}
}
return base.OnExecutingAsync(executingContext, cancellationToken);
}
}
It can then be used to decorate a specific function like this:
[B2cValidation]
[FunctionName("GetTrips")]
public static async Task<IActionResult> GetTrips([HttpTrigger(AuthorizationLevel.Function, "get", Route = "trips")] HttpRequest req,
ILogger log)
{
...
}
It is very elegant and it actually does work! But unfortunately, it seems that it can only throw exceptions. Relecloud was not able to find a way to abort the HTTP request and throw a 401 status code. If an exception is thrown in the filter pipeline, the caller gets a 500 Internal Service Error which is hardly descriptive of the problem.
Eventually, Relecloud received an input from a security expert who advised that the JWT Validation
be added to the code instead of APIM for the very same reason that the the HTTP endpoints will be protected regardless of whether APIM is used or not. To support this, the reference implementation includes a utility method that can be used to check the validation:
public static async Task ValidateToken(HttpRequest request)
{
var validationService = ServiceFactory.GetTokenValidationService();
if (validationService.AuthEnabled)
{
var user = await validationService.AuthenticateRequest(request);
if (user == null)
throw new Exception(Constants.SECURITY_VALITION_ERROR);
}
}
This method is used in each API Function to validate tokens and it throws a known
exception. Upon exception, the function examines the exception to determine whether to send 401 (security check) or 400 (bad request) as shown here:
[FunctionName("GetDrivers")]
public static async Task<IActionResult> GetDrivers([HttpTrigger(AuthorizationLevel.Function, "get",
Route = "drivers")] HttpRequest req,
ILogger log)
{
log.LogInformation("GetDrivers triggered....");
try
{
await Utilities.ValidateToken(req);
var persistenceService = ServiceFactory.GetPersistenceService();
return (ActionResult)new OkObjectResult(await persistenceService.RetrieveDrivers());
}
catch (Exception e)
{
var error = $"GetDrivers failed: {e.Message}";
log.LogError(error);
if (error.Contains(Constants.SECURITY_VALITION_ERROR))
return new StatusCodeResult(401);
else
return new BadRequestObjectResult(error);
}
}
Please note that the token validation is enforced only if the AuthEnabled
setting is set to true.
-
Documentation: the API manager provides developers writing applications against RideShare APIs with a complete development portal for documentation and testing
-
Usage Stats: the API manager provides usage stats on all API calls (and report failures) which makes it really convenient to assess the API performance
-
Rate Limiting: the API manager can be configured to rate limit APIs based on IP origin, access, etc. This can be useful to prevent DOD attacks or provide different tiers of access based on users.
Please note that, in the case of Azure Functions, while the APIs are front-ended with an API manager (and hence shielded, protected and rate limited), the APIs are still publicly available!!! This means that a DOD attack or other attacks can still happen against the bare APIs if someone discovers them in the wide.
The sample contains front-end APIs that are used to manage Drivers, Passengers and Trips:
- They are built on Azure Functions using RESTful design principles.
- They use an Azure Cosmos DB collection to store their respective data. Please note, however, that, due to cost constraints, the sample APIs share the same Cosmos DB collection.
- They use Application Insights to send traces, metrics and telemetry to.
As the macro architecture depicts, the APIs are implemented using C# Azure Functions. They have a simple architecture that can be illustrated as follows:
Please note the following:
- The
Persistence Layer
implements theIPersistenceService
interface. In the reference solution implementation, there are two implementations:CosmosPersistenceLayer
andSqlPersistenceService
. Only theCosmosPersistenceLayer
is fully implemented in the reference implementation:
public interface IPersistenceService
{
// Drivers
Task<DriverItem> RetrieveDriver(string code);
Task<List<DriverItem>> RetrieveDrivers(int max = Constants.MAX_RETRIEVE_DOCS);
Task<List<DriverItem>> RetrieveDrivers(double latitude, double longitude, double miles, int max = Constants.MAX_RETRIEVE_DOCS);
Task<List<DriverItem>> RetrieveActiveDrivers(int max = Constants.MAX_RETRIEVE_DOCS);
Task<int> RetrieveDriversCount();
Task<DriverItem> UpsertDriver(DriverItem driver, bool isIgnoreChangeFeed = false);
Task<string> UpsertDriverLocation(DriverLocationItem driver, bool isIgnoreChangeFeed = false);
Task<List<DriverLocationItem>> RetrieveDriverLocations(string code, int max = Constants.MAX_RETRIEVE_DOCS);
Task DeleteDriver(string code);
// Trips
Task<TripItem> RetrieveTrip(string code);
Task<List<TripItem>> RetrieveTrips(int max = Constants.MAX_RETRIEVE_DOCS);
Task<List<TripItem>> RetrieveTrips(double latitude, double longitude, double miles, int max = Constants.MAX_RETRIEVE_DOCS);
Task<List<TripItem>> RetrieveActiveTrips(int max = Constants.MAX_RETRIEVE_DOCS);
Task<int> RetrieveTripsCount();
Task<int> RetrieveActiveTripsCount();
Task<TripItem> UpsertTrip(TripItem trip, bool isIgnoreChangeFeed = false);
Task DeleteTrip(string code);
// High-level methods
Task<TripItem> AssignTripAvailableDrivers(TripItem trip, List<DriverItem> drivers);
Task<TripItem> AssignTripDriver(TripItem trip, string driverCode);
Task RecycleTripDriver(TripItem trip);
Task<TripItem> CheckTripCompletion(TripItem trip);
Task<TripItem> AbortTrip(TripItem trip);
}
- To make things testable, the Functions are only a wrapper around the PersistenceLayer. Here is an example:
[FunctionName("GetTrips")]
public static async Task<IActionResult> GetTrips([HttpTrigger(AuthorizationLevel.Function, "get", Route = "trips")] HttpRequest req,
ILogger log)
{
log.LogInformation("GetTrips triggered....");
try
{
var persistenceService = ServiceFactory.GetPersistenceService();
return (ActionResult)new OkObjectResult(await persistenceService.RetrieveTrips());
}
catch (Exception e)
{
var error = $"GetTrips failed: {e.Message}";
log.LogError(error);
return new BadRequestObjectResult(error);
}
}
- The
PersistenceService
accepts anIChangeNotifierService
as one of its dependencies. The purpose of this service is to handle entity changes:
public interface IChangeNotifierService
{
Task DriverChanged(DriverItem driver);
Task TripCreated(TripItem trip, int activeTrips);
Task TripDeleted(TripItem trip);
Task PassengerChanged(PassengerItem trip);
}
When a trip is added, for example, the change notifier service implementation triggers the TripManagerOrchestrator
so it creates and assigns a new instance to manage the newly created trip.
In addition, depending on whether the newly created trip is normal
or demo
mode, the change notifier service might trigger the TripDemoOrchestrator
so it creates and assigns a new instance to mimic a demo/robot behavior such as accepting a driver, stepping through a driver route until the final destination is reached. More explanation about this in the Durable Orchestrators section:
public async Task TripCreated(TripItem trip, int activeTrips)
{
var error = "";
try
{
// Start a trip manager
var baseUrl = _settingService.GetStartTripManagerOrchestratorBaseUrl();
var key = _settingService.GetStartTripManagerOrchestratorApiKey();
if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(key))
throw new Exception("Trip manager orchestrator base URL and key must be both provided");
// Trigger the trip manager orchestrator
// ...omitted for brevity
if (trip.Type == TripTypes.Demo)
{
// Trigger the trip demo orchestrator
// ...omitted for brevity
}
}
catch (Exception ex)
{
error = $"Error while starting the trip manager: {ex.Message}";
throw new Exception(error);
}
finally
{
_loggerService.Log($"{LOG_TAG} - TripCreated - Error: {error}");
}
}
Durable Orchestrators are the heart of the solution. They are made up of 3 orchestrators:
- Trip Manager
- Trip Monitor
- Trip Demo (optional)
In the RideShare solution, orchestrators are like Serverless Actors. They are stateful instances running in the Azure Functions container and made persistent to a storage account automatically. Read more about Azure Functions Durable Functions.
Each orchestrator has 3 sections:
- HTTP Trigger Endpoints - used to start, terminate and retrieve state of a particular orchestrator instance.
- Orchestrator Function - used to provide the orchestrator main body of execution and state management.
- Activity Functions - one or more activity functions that the orchestrator calls upon to run the different activities that make up the execution.
To make functions easily identifiable, the reference implementation follows a naming convention where the Trigger Functions start with a T_
i.e. T_StartTripManager
, the Orchestrator Functions start with an O_
i.e. O_ManageTrip
and the Activity Functions start with an A_
and a 2-digit identifier i.e. A_TM_AssignTripDriver
. The _TM_
denotes Trip Manager, for example.
Orchestrator instances require application-level unique instance IDs. In the reference implementation, the Trip code is used as an instance ID for the Trip Manager. The Trip Monitor uses the trip code and appends -M
to make it unique while the Trip Demo uses the trip code and appends -D
to make it unique.
As the macro architecture depicts, the orchestrators are implemented in C#. The following illustrates their overall architecture:
The following describes the process that newly created trips go through:
- The
ChangeNotifierService
triggers the Trip Manager Orchestrator to start a new Trip Manager instance. - The new instance retrieves the available drivers (available & within x miles from trip's source location) and notifies them of a new trip
- The instance then waits for either an external event to arrive (driver accepts the trip) or time out to occur.
- If time out occurs, the instance aborts the trip and exits
- If a driver accepts the trip, the instance assigns the driver to the trip and enqueues the trip code to a storage queue
- The storage queue will trigger the Trip Monitor Orchestrator to start a new Trip Monitor instance.
- The new monitor instance starts the trip and waits for a configurable seconds
- The instance checks for a completion and re-waits until either the trip completes or the the configured number of iterations gets exhausted
- If the number of iterations is exhausted, the instance will abort the trip
- If the trip is in demo mode, the
ChangeNotifierService
triggers both the Trip Manager Orchestrator and the Trip Demo Orchestrator to start new instances
- The
Trip Demo
instance acts like a bot to simulate accepting a driver and navigating through the locations of a random route
Please note that, in the the reference implementation:
- The trip is considered
complete
if the trip's driver location matches the trip's destination location. While this is not realistic, it does provide a method to determine when the trip is complete. In reality though, there has to be a more reliable way of determining completion. - The orchestrators currently use the persistence layer (described above) instead of calling the APIs to retrieve and persist trips. There is a setting in the
ISettingService
that controls this behavior i.e.IsPersistDirectly
. More about this in the source code section. - The route locations that the
Demo
uses to step through the trip's source and destination locations is not really. It is basically the random number of locations made up from the trip's source location and destination location. In real scenarios, Bing Route API can be used to determine the actual route between the source and destination.
The Azure Durable Functions are quite powerful as they provide a way to instantiate thousands of managed stateful instances in a serverless environment. This capability exists in other Azure products such as Service Fabric's stateful actors. The difference is that the Azure Durable Functions require a lot less effort to setup, maintain and code.
Although Azure Durable Functions can query and enumerate all instances of a specific orchestrator:
IList<DurableOrchestrationStatus> instances = await context.GetStatusAsync(); // You can pass CancellationToken as a parameter.
foreach (var instance in instances)
{
log.Info(JsonConvert.SerializeObject(instance));
};
it is still probably a good idea to store the instance ids and their status in a table storage for example in case a solution requires special querying capability against the instances.
Event Grid is a fully-managed event routing service. In the reference implementation, it is used to report Trip
state changes and kick off different Trip
processors. Each processor or handler is an independent Microservice that receives a discrete event and decides for itself what type of action it will need to take. The key advantages of Event Grid Topics are:
- The emitter fires and forgets. No need to wait until a response arrives.
- Events can be delivered to multiple listeners that can process the event data.
- Events have data and meta data such as subject that can be used to determine processing. For example, the `Power BI Trip Processor filters out events based on subject.
Being an event source, the Durable Orchestrators externalize Trip
state changes to an Event Grid Topic upon the following events:
// Event Grid Event Subjects
public const string EVG_SUBJECT_TRIP_DRIVERS_NOTIFIED = "Drivers notified!";
public const string EVG_SUBJECT_TRIP_DRIVER_PICKED = "Driver picked :-)";
public const string EVG_SUBJECT_TRIP_STARTING = "Trip starting :-)";
public const string EVG_SUBJECT_TRIP_RUNNING = "Trip running...";
public const string EVG_SUBJECT_TRIP_COMPLETED = "Trip completed :-)";
public const string EVG_SUBJECT_TRIP_ABORTED = "Trip aborted :-(";
The TripManager
and the TripMonitor
orchestrators have a common routine used by activities to externalize the trip state changes:
private static async Task Externalize(TripItem trip, string subject)
{
await Utilities.TriggerEventGridTopic<TripItem>(null, trip, Constants.EVG_EVENT_TYPE_MONITOR_TRIP, subject, ServiceFactory.GetSettingService().GetTripExternalizationsEventGridTopicUrl(), ServiceFactory.GetSettingService().GetTripExternalizationsEventGridTopicApiKey());
}
Please note that the code uses a Utility
method to post the TripItem
to an Event Grid Topic using the Topic's Endpoint and API Key as identified by the setting service.
A TripItem
is defined this way:
public class TripItem : BaseItem
{
[JsonProperty(PropertyName = "code")]
public string Code { get; set; } = "";
// Included here ...just in case the passenger state changed ...this captures the passenger state at the time of the trip
[JsonProperty(PropertyName = "passenger")]
public PassengerItem Passenger { get; set; } = new PassengerItem();
// Included here ...just in case the driver state changed ...this captures the driver state at the time of the trip
[JsonProperty(PropertyName = "driver")]
public DriverItem Driver { get; set; } = null;
// Included here ...just in case the driver state changed ...this captures the available drivers state at the time of the trip
[JsonProperty(PropertyName = "availableDrivers")]
public List<DriverItem> AvailableDrivers { get; set; } = new List<DriverItem>();
[JsonProperty(PropertyName = "source")]
public TripLocation Source { get; set; } = new TripLocation();
[JsonProperty(PropertyName = "destination")]
public TripLocation Destination { get; set; } = new TripLocation();
[JsonProperty(PropertyName = "acceptDate")]
public DateTime? AcceptDate { get; set; } = null;
[JsonProperty(PropertyName = "startDate")]
public DateTime StartDate { get; set; } = DateTime.Now;
[JsonProperty(PropertyName = "endDate")]
public DateTime? EndDate { get; set; } = null;
// Computed values
[JsonProperty(PropertyName = "duration")]
public double Duration { get; set; } = 0;
[JsonProperty(PropertyName = "monitorIterations")]
public int MonitorIterations { get; set; } = 0;
[JsonProperty(PropertyName = "isAborted")]
public bool IsAborted { get; set; } = false;
[JsonProperty(PropertyName = "error")]
public string Error { get; set; } = "";
[JsonProperty(PropertyName = "type")]
public TripTypes Type { get; set; } = TripTypes.Normal;
}
As shown in the macro architecture section, the solution implements several listeners for the trip:
Logic Apps provide a special trigger for Event Grids. When selected, the connector handles all the things needed to provide the web hook required to subscribe to the Event Grid topic. Please refer to the setup to see how to set this up.
In the reference implementation, the Logic App is triggered by the Event Grid Topic to notify admins of trip state changes:
Please note that the Logic Apps Event Grid trigger exposes the event's meta data as dynamic content. To access the event data, you must switch to Code view
i.e. @{triggerBody()?['data']}
.
Azure Functions provide a special binding trigger EventGridEvent
to handle the Event Grid event. In addition, there is a new special binding for SignalR Service which makes broadcasting SignalR messages super flexible.
[FunctionName("EVGH_TripExternalizations2SignalR")]
public static async Task ProcessTripExternalizations2SignalR([EventGridTrigger] EventGridEvent eventGridEvent,
[SignalR(HubName = "trips")] IAsyncCollector<SignalRMessage> signalRMessages,
ILogger log)
{
log.LogInformation($"ProcessTripExternalizations2SignalR triggered....EventGridEvent" +
$"\n\tId:{eventGridEvent.Id}" +
$"\n\tTopic:{eventGridEvent.Topic}" +
$"\n\tSubject:{eventGridEvent.Subject}" +
$"\n\tType:{eventGridEvent.EventType}" +
$"\n\tData:{eventGridEvent.Data}");
try
{
TripItem trip = JsonConvert.DeserializeObject<TripItem>(eventGridEvent.Data.ToString());
if (trip == null)
throw new Exception("Trip is null!");
log.LogInformation($"ProcessTripExternalizations2SignalR trip code {trip.Code}");
// Convert the `event subject` to a method to be called on clients
var clientMethod = "tripUpdated";
if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_DRIVERS_NOTIFIED)
clientMethod = "tripDriversNotified";
else if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_DRIVER_PICKED)
clientMethod = "tripDriverPicked";
else if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_STARTING)
clientMethod = "tripStarting";
else if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_RUNNING)
clientMethod = "tripRunning";
else if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_COMPLETED)
clientMethod = "tripCompleted";
else if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_ABORTED)
clientMethod = "tripAborted";
log.LogInformation($"ProcessTripExternalizations2SignalR firing SignalR `{clientMethod}` client method!");
await signalRMessages.AddAsync(new SignalRMessage()
{
Target = clientMethod,
Arguments = new object[] { trip}
});
}
catch (Exception e)
{
var error = $"ProcessTripExternalizations2SignalR failed: {e.Message}";
log.LogError(error);
throw e;
}
}
Please note that, in the reference implementation, EVGH_
is added to the function name that handles an Event Grid event i.e. EVGH_TripExternalizations2SignalR
.
When an Event Grid Topic event arrives at the SignalR processor, it extracts the TripItem
from the event data and calls different client methods based on the event subject to notify SignalR clients, in real-time, of trip state changes.
In this reference implementation, the SignalR client is the Web App SPA. But a Xamarin Mobile App or .NET client can also receive SignalR messages. When a client receives a SignalR message, they change the trip state so passengers and drivers become aware of the latest trip status.
Below we provide two sample SignalR client implementations: .NET SignalR client and JavaScript SignalR client.
The following is sample .NET SignalR client written to receive the SignalR
messages emitted by the SignalR
handler:
// Get the SignalR service url and access token by calling the `signalrinfo` API
var singnalRInfo = await GetSignalRInfo();
if (singnalRInfo == null)
throw new Exception("SignalR info is NULL!");
var connection = new HubConnectionBuilder()
.WithUrl(singnalRInfo.Endpoint, option =>
{
option.AccessTokenProvider = () =>
{
return Task.FromResult(singnalRInfo.AccessKey);
};
})
.ConfigureLogging( logging =>
{
logging.AddConsole();
})
.Build();
connection.On<TripItem>("tripUpdated", (trip) =>
{
Console.WriteLine($"tripUpdated - {trip.Code}");
});
connection.On<TripItem>("tripDriversNotified", (trip) =>
{
Console.WriteLine($"tripDriversNotified - {trip.Code}");
});
connection.On<TripItem>("tripDriverPicked", (trip) =>
{
Console.WriteLine($"tripDriverPicked - {trip.Code}");
});
connection.On<TripItem>("tripStarting", (trip) =>
{
Console.WriteLine($"tripStarting - {trip.Code}");
});
connection.On<TripItem>("tripRunning", (trip) =>
{
Console.WriteLine($"tripRunning - {trip.Code}");
});
connection.On<TripItem>("tripCompleted", (trip) =>
{
Console.WriteLine($"tripCompleted - {trip.Code}");
});
connection.On<TripItem>("tripAborted", (trip) =>
{
Console.WriteLine($"tripAborted - {trip.Code}");
});
await connection.StartAsync();
Console.WriteLine("SignalR client started....waiting for messages from server. To cancel......press any key!");
Console.ReadLine();
Where GetSignalRInfo
retrieves via a Get
operation the SignalR Info
from a Function also defined in the Trips Function App
:
[FunctionName("GetSignalRInfo")]
public static IActionResult GetSignalRInfo([HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "signalrinfo")] HttpRequest req,
[SignalRConnectionInfo(HubName = "trips")] AzureSignalRConnectionInfo info,
ILogger log)
{
log.LogInformation("GetSignalRInfo triggered....");
try
{
if (info == null)
throw new Exception("SignalR Info is null!");
return (ActionResult)new OkObjectResult(info);
}
catch (Exception e)
{
var error = $"GetSignalRInfo failed: {e.Message}";
log.LogError(error);
return new BadRequestObjectResult(error);
}
}
The following is sample JavaScript SignalR client written to receive the SignalR
messages emitted by the SignalR
handler:
let signalRInfoUrl = "<trips-function-app-base-url>/api/signalrinfo";
let hubConnection = {};
getSignalRInfoAsync = async (url) => {
console.log(`SignalR Info URL ${url}`);
const rawResponse = await fetch(url, {
method: "GET", // *GET, POST, PUT, DELETE, etc.
mode: "cors", // no-cors, cors, *same-origin
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
credentials: "same-origin", // include, same-origin, *omit
headers: {
"Content-Type": "application/json; charset=utf-8"
},
redirect: "follow", // manual, *follow, error
referrer: "no-referrer" // no-referrer, *client
});
if (rawResponse.status === 200) {
let signalRInfo = await rawResponse.json();
console.log(signalRInfo);
console.log(signalRInfo.accessKey);
console.log(signalRInfo.endpoint);
return signalRInfo;
} else {
alert(`getSignalRInfoAsync Response status: ${rawResponse.status}`);
return null;
}
}
document.getElementById("start").addEventListener("click", async e => {
e.preventDefault();
let info = await getSignalRInfoAsync(signalRInfoUrl);
if (info != null) {
let options = {
accessTokenFactory: () => info.accessKey
};
hubConnection = new signalR.HubConnectionBuilder()
.withUrl(info.endpoint, options)
.configureLogging(signalR.LogLevel.Information)
.build();
hubConnection.on('tripUpdated', (trip) => {
console.log(`tripUpdated: ${trip.code}`);
});
hubConnection.on('tripDriversNotified', (trip) => {
console.log(`tripDriversNotified: ${trip.code}`);
});
hubConnection.on('tripDriverPicked', (trip) => {
console.log(`tripDriverPicked: ${trip.code}`);
});
hubConnection.on('tripStarting', (trip) => {
console.log(`tripStarting: ${trip.code}`);
});
hubConnection.on('tripRunning', (trip) => {
console.log(`tripRunning: ${trip.code}`);
});
hubConnection.on('tripCompleted', (trip) => {
console.log(`tripCompleted: ${trip.code}`);
});
hubConnection.on('tripAborted', (trip) => {
console.log(`tripAborted: ${trip.code}`);
});
hubConnection.start().catch(err => console.error(err.toString()));
}
});
Similar to the SignalR handler above, the Power BI Event Grid handler uses the special binding trigger EventGridEvent
to process the event:
[FunctionName("EVGH_TripExternalizations2PowerBI")]
public static async Task ProcessTripExternalizations2PowerBI([EventGridTrigger] EventGridEvent eventGridEvent,
ILogger log)
{
log.LogInformation($"ProcessTripExternalizations2PowerBI triggered....EventGridEvent" +
$"\n\tId:{eventGridEvent.Id}" +
$"\n\tTopic:{eventGridEvent.Topic}" +
$"\n\tSubject:{eventGridEvent.Subject}" +
$"\n\tType:{eventGridEvent.EventType}" +
$"\n\tData:{eventGridEvent.Data}");
try
{
TripItem trip = JsonConvert.DeserializeObject<TripItem>(eventGridEvent.Data.ToString());
if (trip == null)
throw new Exception("Trip is null!");
log.LogInformation($"ProcessTripExternalizations2PowerBI trip code {trip.Code}");
if (eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_ABORTED ||
eventGridEvent.Subject == Constants.EVG_SUBJECT_TRIP_COMPLETED)
{
var archiveService = ServiceFactory.GetArchiveService();
await archiveService.UpsertTrip(trip);
var powerBIService = ServiceFactory.GetPowerBIService();
await powerBIService.UpsertTrip(trip);
}
}
catch (Exception e)
{
var error = $"ProcessTripExternalizations2PowerBI failed: {e.Message}";
log.LogError(error);
throw e;
}
}
Please note that, in the reference implementation, EVGH_
is added to the function name that handles an Event Grid event i.e. EVGH_TripExternalizations2SignalR
.
When an Event Grid Topic event arrives at the Power BI processor, it extracts the TripItem
from the event data and, if the event subject is either completed
or aborted
, it:
- Persists the trip in Azure SQL Database.
- Optionally, sends the trip to a streaming dataset in Power BI.
In addition to archiving trip summaries, persisting to an Azure SQL Database provides a way to report on trips using Power BI for example. A Power BI report can provide RideShare management with several performance indicators such:
- Total Trips
- Average Trip Duration
- Top Drivers
- Top Passengers
- Average Available Drivers
- Etc.
This is a sample Power BI report against test trip data:
Sending trips to a streaming Power BI dataset provides a way to display real-time trip information on a Power BI dashboard. This is great for product launches but it is outside the scope of this reference implementation.
Similar to the Power BI handler above, the Trip Archiver Event Grid handler uses the special binding trigger EventGridEvent
to process the event, however as shown below, this function was written using Node.js instead of C#:
index.js
{
"bindings": [
{
"type": "eventGridTrigger",
"name": "eventGridEvent",
"direction": "in"
},
{
"type": "documentDB",
"name": "document",
"databaseName": "RideShare",
"collectionName": "Archive",
"createIfNotExists": false,
"connection": "DocDbConnectionStringKey",
"direction": "out"
}
],
"disabled": false
}
function.json
module.exports = function(context, eventGridEvent) {
context.log(typeof eventGridEvent);
context.log(eventGridEvent);
context.log('JavaScript Event Grid function processed a request.');
context.log('Subject: ' + eventGridEvent.subject);
context.log('Time: ' + eventGridEvent.eventTime);
context.log('Data: ' + JSON.stringify(eventGridEvent.data));
context.bindings.document = JSON.stringify(eventGridEvent.data);
context.done();
};
Please note that, in the reference implementation, EVGH_
is added to the function name that handles an Event Grid event i.e. EVGH_TripExternalizations2CosmosDB
.
When an Event Grid Topic event arrives at the Trip Archiver processor, it extracts the TripItem
from the event data and it:
- Persists the trip in the Cosmos DB Archiver Collection.
The Relecloud Rideshare website is a single page application (SPA) written in Vue.js. It is here that users sign in with an Azure Active Directory B2C account, access passenger and driver information, and request new trips. Each HTTP request flows through the API Management endpoints to each of the underlying Azure Functions that serve those requests.
This page displays passenger information that is stored within Azure Active Directory B2C, using the Microsoft Graph API. When the passengers
GET request is made to API Management, that request is routed to the GetPassengers
function within the Passengers Function App.
// Excerpt from the api/passengers.js file within the SPA website:
import { checkResponse, get } from '@/utils/http';
const baseUrl = window.apiPassengersBaseUrl;
const apiKey = window.apiKey;
// GET methods
export function getPassengers() {
return get(`${baseUrl}/passengers`, {}, apiKey).then(checkResponse);
}
// GetPassengers function within the Passengers Function App:
[FunctionName("GetPassengers")]
public static async Task<IActionResult> GetPassengers([HttpTrigger(AuthorizationLevel.Anonymous, "get",
Route = "passengers")] HttpRequest req,
ILogger log)
{
log.LogInformation("GetPassengers triggered....");
try
{
await Utilities.ValidateToken(req);
var passengers = ServiceFactory.GetUserService();
var (users, error) = await passengers.GetUsers();
if (!string.IsNullOrWhiteSpace(error))
throw new Exception(error);
return (ActionResult)new OkObjectResult(users.ToList());
}
catch (Exception e)
{
var error = $"GetPassengers failed: {e.Message}";
log.LogError(error);
if (error.Contains(Constants.SECURITY_VALITION_ERROR))
return new StatusCodeResult(401);
else
return new BadRequestObjectResult(error);
}
}
The UserService.GetUsers
method makes a secure call to the Microsoft Graph API as in the following excerpt:
const string GraphBaseUrl = "https://graph.windows.net/";
const string GraphVersionQueryString = "?" + GraphVersion;
const string GraphVersion = "api-version=1.6";
private readonly AuthenticationContext _authContext;
private readonly ClientCredential _clientCreds;
private readonly string _graphUrl;
public UserService(string tenantId, string clientId, string clientSecret)
{
_graphUrl = GraphBaseUrl + tenantId;
var authority = "https://login.microsoftonline.com/" + tenantId;
_authContext = new AuthenticationContext(authority);
_clientCreds = new ClientCredential(clientId, clientSecret);
}
// Code removed for brevity...
public async Task<(IEnumerable<User>, string error)> GetUsers()
{
var url = _graphUrl + "/users" + GraphVersionQueryString;
// Call with HttpClient:
var response = await client.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<UsersResult>(json);
return (result.Value, null);
}
else if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
var json = await response.Content.ReadAsStringAsync();
var badRequest = JsonConvert.DeserializeObject<BadRequestResponse>(json);
return (null, badRequest.ErrorMessage);
}
else
{
return (null, "Error Getting Users. HTTP Status Code: " + (int)response.StatusCode);
}
}
The Microsoft Graph data is deserialized to a UsersResult
object containing a collection of User
strongly-typed class objects that store the user profile data that is ultimately returned to the client in JSON format.
When you select a user, additional information about the user is displayed in a modal window.
The Drivers page displays driver information that is stored in Cosmos DB. The information includes the unique driver code, driver location, and whether they are currently accepting rides. If they are currently linked to an active trip, the accepting rides status is set to false
. If no drivers within proximity to a passenger are accepting rides at the time of a trip request, the trip request will ultimately fail with a warning that no drivers are available if one does not accept before the configured time out period.
When the drivers
GET request is made to API Management, that request is routed to the GetDrivers
function within the Drivers Function App.
// Excerpt from the api/passengers.js file within the SPA website:
import { checkResponse, post, get, put } from '@/utils/http';
const baseUrl = window.apiDriversBaseUrl;
const apiKey = window.apiKey;
// GET methods
export function getDrivers() {
return get(`${baseUrl}/drivers`, {}, apiKey).then(checkResponse);
}
// GetDrivers function within the Drivers Function App:
[FunctionName("GetDrivers")]
public static async Task<IActionResult> GetDrivers([HttpTrigger(AuthorizationLevel.Anonymous, "get",
Route = "drivers")] HttpRequest req,
ILogger log)
{
log.LogInformation("GetDrivers triggered....");
try
{
await Utilities.ValidateToken(req);
var persistenceService = ServiceFactory.GetPersistenceService();
return (ActionResult)new OkObjectResult(await persistenceService.RetrieveDrivers());
}
catch (Exception e)
{
var error = $"GetDrivers failed: {e.Message}";
log.LogError(error);
if (error.Contains(Constants.SECURITY_VALITION_ERROR))
return new StatusCodeResult(401);
else
return new BadRequestObjectResult(error);
}
}
The GetDrivers
function calls the RetrieveDrivers
method from the IPersistenceService
implementation. In this case we using the CosmosPersistenceService
to handle the request and pull the data from Cosmos DB:
public async Task<List<DriverItem>> RetrieveDrivers(int max = Constants.MAX_RETRIEVE_DOCS)
{
var error = "";
double cost = 0;
try
{
if (string.IsNullOrEmpty(_docDbDigitalMainCollectionName))
throw new Exception("No Digital Main collection defined!");
FeedOptions queryOptions = new FeedOptions { MaxItemCount = max };
var query = (await GetDocDBClient(_settingService)).CreateDocumentQuery<DriverItem>(
UriFactory.CreateDocumentCollectionUri(_docDbDatabaseName, _docDbDigitalMainCollectionName), queryOptions)
.Where(e => e.CollectionType == ItemCollectionTypes.Driver)
.OrderByDescending(e => e.UpsertDate)
.Take(max)
.AsDocumentQuery();
List<DriverItem> allDocuments = new List<DriverItem>();
while (query.HasMoreResults)
{
var queryResult = await query.ExecuteNextAsync<DriverItem>();
cost += queryResult.RequestCharge;
allDocuments.AddRange(queryResult.ToList());
}
return allDocuments;
}
catch (Exception ex)
{
error = ex.Message;
throw new Exception(error);
}
finally
{
_loggerService.Log($"{LOG_TAG} - RetrieveDrivers - Error: {error}");
}
}
When you select a driver, their information will appear within a modal window, including their car information that is displayed to a passenger when the driver has accepted their trip request.
Azure Active Directory B2C is used for user authentication and profile management. With it, users can self-service their accounts, which means they are able to register for a new account, manage their profile information (mailing address, phone number, etc.), and initiate a password reset if needed.
The screenshot above shows the home page of the website with the login form displayed in a popup window after selecting the Login link on the page menu. The features are as follows:
- User selects Login on the page menu.
- The
msal
library requests the login form popup from Azure Active Directory B2C via the following command:this._userAgentApplication.loginPopup(this._scopes)
- A user may register for a new account by selecting the Sign up now link.
- If a user forgets their password, they can reset it with the Forgot your password? link.
If you attempt to access a protected page, such as My Trip, Passengers, or Drivers, you will be prompted to log in before continuing:
The utils
folder contains a file named Authentication.js
, which wraps the Microsoft Authentication Library (MSAL), enabling the client to easily log in and out of their Azure Active Directory B2C account:
import { UserAgentApplication, Logger } from 'msal';
User settings are supplied by the public/js/settings.js
file, which are used when instantiating a new UserAgentApplication
class:
export class Authentication {
constructor() {
// The window values below should by set by public/js/settings.js
this._scopes = window.authScopes;
this._clientId = window.authClientId;
this._authority = window.authAuthority;
var cb = this._tokenCallback.bind(this);
var opts = {
validateAuthority: false
};
this._userAgentApplication = new UserAgentApplication(
this._clientId,
this._authority,
cb,
opts
);
}
_tokenCallback(errorDesc, token, error, tokenType) {
this._error = error;
if (tokenType === 'access_token') {
this._token = token;
}
}
Now that we have a reference to msal
's UserAgentApplication
, we can use it to easily authenticate the user and perform other tasks against Azure Active Directory B2C:
getUser() {
return this._userAgentApplication.getUser();
}
getAccessToken() {
return this._userAgentApplication.acquireTokenSilent(this._scopes).then(
accessToken => {
return accessToken;
},
error => {
return this._userAgentApplication.acquireTokenPopup(this._scopes).then(
accessToken => {
return accessToken;
},
err => {
console.error(err);
}
);
}
);
}
login() {
return this._userAgentApplication.loginPopup(this._scopes).then(
idToken => {
const user = this._userAgentApplication.getUser();
if (user) {
return user;
} else {
return null;
}
},
() => {
return null;
}
);
}
Also in the utils
folder is an HTTP helper (http.js
) that standardizes HTTP calls to Azure. The getHeaders
method applies default headers, including the authorization header if a token is present:
function getHeaders(token, apiKey) {
let defaultHeaders = '';
let authHeaders = '';
if (apiKey) {
defaultHeaders = {
'Cache-Control': 'no-cache',
'Ocp-Apim-Trace': true,
'Ocp-Apim-Subscription-Key': apiKey
};
}
if (token) {
authHeaders = {
Authorization: `Bearer ${token}`
};
if (apiKey) {
defaultHeaders = { ...defaultHeaders, ...authHeaders };
} else {
defaultHeaders = authHeaders;
}
}
return defaultHeaders;
}
Each HTTP method ensures these headers are added to each request:
export function post(uri, data, apiKey) {
return auth.getAccessToken().then(token => {
return axios.post(uri, data, {
headers: getHeaders(token, apiKey),
withCredentials: false
});
});
}
export function put(uri, data, apiKey) {
return auth.getAccessToken().then(token => {
return axios.put(uri, data, {
headers: getHeaders(token, apiKey),
withCredentials: false
});
});
}
export function remove(uri, apiKey) {
return auth.getAccessToken().then(token => {
return axios.delete(uri, {
headers: getHeaders(token, apiKey),
withCredentials: false
});
});
}
export function get(uri, data = {}, apiKey) {
if (Object.keys(data).length > 0) {
uri = `${uri}?${qs(data)}`;
}
return auth.getAccessToken().then(token => {
return axios.get(uri, {
headers: getHeaders(token, apiKey),
withCredentials: false
});
});
The HTTP helper helps simplify API calls and ensure standardization across calls to the microservices endpoints. The api
folder contains files for each of these services (Drivers, Passengers, Trips) that are accessed by the website.
Here is a sample from the drivers.js
API file, which uses the HTTP helper:
import { checkResponse, post, get, put } from '@/utils/http';
const baseUrl = window.apiDriversBaseUrl;
const apiKey = window.apiKey;
// GET methods
export function getDrivers() {
return get(`${baseUrl}/drivers`, {}, apiKey).then(checkResponse);
}
export function getDriver(driverCode) {
return get(`${baseUrl}/drivers/${driverCode}`, {}, apiKey).then(
checkResponse
);
}
export function getActiveDrivers() {
return get(`${baseUrl}/activedrivers`, {}, apiKey).then(checkResponse);
}
export function getDriversWithinLocation(latitude, longitude, miles) {
return get(
`${baseUrl}/drivers/${latitude}/${longitude}/${miles}`,
{},
apiKey
).then(checkResponse);
}
export function getDriverLocationChanges(driverCode) {
return get(`${baseUrl}/driverlocations/${driverCode}`, {}, apiKey).then(
checkResponse
);
}
// POST methods
export function createDriver(driver) {
return post(`${baseUrl}/drivers`, driver, apiKey).then(checkResponse);
}
// PUT methods
export function updateDriver(driver) {
return put(`${baseUrl}/drivers`, driver, apiKey).then(checkResponse);
}
export function updateDriverLocation(driver) {
return put(`${baseUrl}/driverlocations`, driver, apiKey).then(checkResponse);
}
As covered earlier in this document, the SignalR Service makes it very easy to push real-time messages through a websocket connection between the website and the Azure Function that serves as the SignalR Service handler microservice.
As an example, the customer visits the "My Trip" page on the website to request a new trip. They start out by selecting the pickup location and their destination. When they select Request Driver, the following steps take place:
-
The
requestDriver
method is called within theTrip.vue
view. The passenger information is retrieved, using the signed in user's token, and this information along with the trip parameters are sent to thecreateTrip
method within thestore/trips.js
file, which in turn updates the trip state and calls thecreateTrip
method in theapi/trips.js
file:// Trip.vue file excerpt: methods: { ...commonActions(['setUser']), ...tripActions(['setTrip', 'setCurrentStep', 'createTrip']), createTripRequest(trip) { this.createTrip(trip) .then(response => { this.setCurrentStep(1); this.$toast.success( `Request Code: <b>${response.code}`, 'Driver Requested Successfully', this.notificationSystem.options.success ); }) .catch(err => { this.$toast.error( err.response ? err.response : err.message ? err.message : err, 'Error', this.notificationSystem.options.error ); }); }, requestDriver() { if (this.user) { getPassenger(this.user.idToken.oid) .then(response => { this.passengerInfo = response.data; var trip = { passenger: { code: this.passengerInfo.email, firstName: this.passengerInfo.givenName, surname: this.passengerInfo.surname, //"mobileNumber": this.passengerInfo.mobileNumber, email: this.passengerInfo.givenName }, source: { latitude: this.selectedPickUpLocation.latitude, longitude: this.selectedPickUpLocation.longitude }, destination: { latitude: this.selectedDestinationLocation.latitude, longitude: this.selectedDestinationLocation.longitude }, type: 1 //0 = Normal, 1 = Demo }; this.createTripRequest(trip); }) .catch(err => { this.$toast.error( err.response, 'Error', this.notificationSystem.options.error ); }); } else { this.$toast.error( 'You must be logged in to start a new trip!', 'Error', this.notificationSystem.options.error ); } }
// store/trips.js excerpt: async createTrip({ commit }, value) { try { commit('contentLoading', true); let trip = await createTrip(value); commit('trip', trip.data); return trip.data; } catch (e) { throw e; } finally { commit('contentLoading', false); } }
// api/trips.js file: import { checkResponse, post } from '@/utils/http'; const baseUrl = window.apiTripsBaseUrl; const apiKey = window.apiKey; // POST methods export function createTrip(trip) { return post(`${baseUrl}/trips`, trip, apiKey).then(checkResponse); }
-
On this page, a Driver Requested Successfully toast message is displayed to the user, the Trip requested step is highlighted, and the user is told that Rideshare is searching for a nearby driver.
-
The API Management /trips endpoint routes the request to the
CreateTrip
function within the Trips Function App. This function validates the authentication token, validates the passenger information, and finally calls theUpsertTrip
method within the Persistence Layer:[FunctionName("CreateTrip")] public static async Task<IActionResult> CreateTrip([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "trips")] HttpRequest req, ILogger log) { log.LogInformation("CreateTrip triggered...."); try { await Utilities.ValidateToken(req); string requestBody = new StreamReader(req.Body).ReadToEnd(); TripItem trip = JsonConvert.DeserializeObject<TripItem>(requestBody); // validate if (trip.Passenger == null || string.IsNullOrEmpty(trip.Passenger.Code)) throw new Exception("A passenger with a valid code must be attached to the trip!!"); trip.EndDate = null; var persistenceService = ServiceFactory.GetPersistenceService(); return (ActionResult)new OkObjectResult(await persistenceService.UpsertTrip(trip)); } catch (Exception e) { var error = $"CreateTrip failed: {e.Message}"; log.LogError(error); if (error.Contains(Constants.SECURITY_VALITION_ERROR)) return new StatusCodeResult(401); else return new BadRequestObjectResult(error); } }
-
The
UpsertTrip
method within thePersistence Layer
saves the trip information to Cosmos DB and calls theTripCreated
method of theChangeNotifierService
to initiate the Trip Manager Durable Orchestrator, as outlined in the Durable Orchestrators section:// Excerpt from the CosmosPersistenceService.UpsertTrip method: var response = await (await GetDocDBClient(_settingService)).UpsertDocumentAsync(UriFactory.CreateDocumentCollectionUri(_docDbDatabaseName, _docDbDigitalMainCollectionName), trip); if (!isIgnoreChangeFeed && blInsert) { await _changeNotifierService.TripCreated(trip, await RetrieveActiveTripsCount()); }
// Excerpt from the ChangeNotifierService.TripCreated method: // Start a trip manager if (!_settingService.IsEnqueueToOrchestrators()) { var baseUrl = _settingService.GetStartTripManagerOrchestratorBaseUrl(); var key = _settingService.GetStartTripManagerOrchestratorApiKey(); if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(key)) throw new Exception("Trip manager orchestrator base URL and key must be both provided"); await Utilities.Post<dynamic, dynamic>(null, trip, $"{baseUrl}/tripmanagers?code={key}", new Dictionary<string, string>()); } else { await _storageService.Enqueue(trip); }
-
From here, the Trip Manager Durable Orchestrator is triggered, which in turn triggers the Trip Monitor Durable Orchestrator. As the trip progresses, new Event Grid events are fired to trigger actions by multiple listeners, including the SignalR Azure Functions handler. The
/components/SignalRTrips.vue
file contains the JavaScript SignalR client code that connects to the SignalR Service and receives and processes each message. In the code excerpt below, we are handling thetripDriverPicked
SignalR message, updating the current trip step, setting the local trip state to display to the user, and firing the toast notification:hubConnection.on('tripDriverPicked', trip => { console.log(`tripDriverPicked Trip code: ${trip.code}`); this.setCurrentStep(2); this.setTrip(trip); this.$toast.info( `Trip Code: ${trip.code}. Message: tripDriverPicked.`, 'Driver Picked', this.notificationSystem.options.info ); });
The following is a screenshot of the My Trip page that is updated in real-time as a result of the SignalR messages flowing to the SPA website:
These are the following features of this page:
- Toast message showing the trip status, appropriate to the current step of the trip. In this case, the
tripCompleted
SignalR message was received. - Visual trip progress indicator highlights the current stage of the trip as it progresses (
this.setCurrentStep(n)
). - The driver information is displayed after a driver is selected. This happens when the
tripDriverPicked
SignalR message is received by updating the local trip state with thethis.setTrip(trip)
command.
Relecloud decided to use Azure Cosmos DB as the main data storage for the solution entities. Since Relecloud targets a world-wide audience accessing its services from different parts of the world, Cosmos DB provides key advantages:
- A global distribution capability replicates data in different Azure Data centers around the world making the data closer to consumers thereby reducing the response time.
- Independent storage and throughput scale capability allows for great granularity and flexibility that can be used to adjust for unpredictable usage patterns.
- Being the main centric entities in the solution,
Trip
entities capture the trip state such as the associated driver, the associated passenger, the available drivers and many other metrics. It is more convenient to query and storeTrip
entities as a whole without requiring transformation or complex object to relational mapping layers. - Trip schema can change without having to go through database schema changes. Only the application code will have to adjust to the schema changes.
Please note that the Cosmos DB Main
and Archive
collections used in the reference implementation use fixed data size and the minimum 400 RUs without a partition key. This will have to be addressed in a real solution.
In addition to Azure Cosmos DB, Relecloud decided to use Azure SQL Database to persist trip summaries so they can be reported on in Power BI, for example. Please refer to Power BI Handler section for details on this.
The .NET solution consists of 7 projects:
- The
Models
project defines all the model classes required by RideShare - The
Shared
project contains all the services which are used by the functions to provide different functionality - The
Seeder
project contains some integration tests to pump trips through the solution - The
Drivers
Function App project contains theDrivers
APIs - The
Trips
Function App project contains theTrips
APIs - The
Passengers
Function App project contains thePassengers
APIs - The
Orchestrators
Function App project contains theOrchestrators
The following are some notes about the source code:
- The
Factory
pattern is used to create static singleton instances via theServiceFactory
:
private static ISettingService _settingService = null;
public static ISettingService GetSettingService()
{
if (_settingService == null)
_settingService = new SettingService();
return _settingService;
}
- The
ISettingService
service implementation is used to read settings from environment variables:
var seconds = _settingService.GetTripMonitorIntervalInSeconds();
var maxIterations = _settingService..GetTripMonitorMaxIterations();
- The
ILoggerService
service implementation sends traces, exceptions, custom events and metrics to theApplication Insights
resource associated with the Function App:
// Send a trace
_loggerService.Log($"{LOG_TAG} - TripCreated - Error: {error}");
// Send an event telemetry
_loggerService.Log("Trip created", new Dictionary<string, string>
{
{"Code", trip.Code },
{"Passenger", $"{trip.Passenger.FirstName} {trip.Passenger.LastName}" },
{"Destination", $"{trip.Destination.Latitude} - {trip.Destination.Longitude}" },
{"Mode", $"{trip.Type}" }
});
// Send a metric telemetry
_loggerService.Log("Active trips", activeTrips);
IPersistenceService
has two implementations:CosmosPersistenceService
andSqlPersistenceService
. The Azure Cosmos DB implementation is complete and used in the APIs while the SQL implementation is partially implemented and only used in theTripExternalizations2PowerBI
handler to persist trip summaries to SQL.- The
CosmosPersistenceService
assigns Cosmos DB IDs manually, which is a combination of thecollection type
and some identifier. Cosmos DB'sReadDocumentAsync
retrieves really fast if anid
is provided. - The
IsPersistDirectly
setting is used mainly by the orchestrators to determine whether to communicate with the storage directly (via the persistence layer) or whether to use the exposed APIs to retrieve and update. In the reference implementation, theIsPersistDirectly
setting is set to true.
The nodejs folder contains the Archiver Function App with the following folder structure:
- The serverless-microservices-functionapp-triparchiver folder contains the Archiver Function App.
- The EVGH_TripExternalizations2CosmosDB folder contains the function to send data to the Archiver Collection in Azure Cosmos DB:
- function.json: Defines the function's in (eventGridTrigger) and out (documentDB) bindings.
- index.js: The function code that defines the data to be sent.
- .gitignore: Local Git ignore file.
- host.json: This file can include global configuration settings that affect all functions for this function app.
- local.settings.json: This file can include configuration settings needed when running the functions locally.
The web folder contains the Vue.js-based SPA website with the following folder structure:
- The public folder contains the
index.html
page, as well asjs
folder that contains important settings for the SPA. Thesettings.sample.js
file is included and shows the expected settings for reference. Thesettings.js
file is excluded to prevent sensitive data from leaking. This file is added via the Web App's debug console (Kudu) after deploying the website. - The src folder contains the bulk of the files:
- api: these files use the http helper (
utils/http.js
) to execute REST calls against the API Management endpoints. - assets: site images.
- components: Vue.js components, including a SignalR component that contains the client-side functions called by the SignalR Service.
- store: Vuex store, which represents the state management components for the SPA site.
- utils: utilities for authentication (wraps the Microsoft Authentication Library (MSAL)) and HTTP (wraps the Axios library)
- views: Vue.js files for each of the SPA "pages".
- api: these files use the http helper (
The .NET ServerlessMicroservices.Seeder
project contains a multi-thread tester that can be used to submit demo
trip requests against the Trips
API. The test will simulate load on the deployed solution and test end-to-end.
Please note that the test will usually run against a deployment environment where the AuthEnabled
setting is set to false.
The testTrips
command takes 1 mandatory argument and 2 optional arguments i.e. ServerlessMicroservices.Seeder testTrips testUrl testiterations testseconds
- Test Parameters URL to read the test data from.
- Optional: # of iterations. Default to 1.
- Optional: # of seconds to delay between each iteration. Default to 60.
The Test Parameters URL is the RetrieveTripTestParameters
endpoint defined in the Trips API Function App. It reads test parameters stored in blob storage i.e. . The blob storage is written to by the StoreTripTestParameters
endpoint defined in the Trips API Function App.
The following is a sample POST payload the StoreTripTestParameters
API i.e. https://<your-trips-function-api>.azurewebsites.net/api/triptestparameters?code=<your code>
. It defines 4 trips to run simultaneously:
[
{
"url": "https://<your-trips-function-app>.azurewebsites.net/api/trips?code=<your code>",
"passengerCode": "[email protected]",
"passengerFirstName": "Bill",
"passengerLastName": "Sam",
"PassengerMobile": "50551000",
"PassengerEmail": "[email protected]",
"sourceLatitude": 31,
"sourceLongitude": 50,
"destinationLatitude": 32,
"destinationLongitude": 60
},
{
"url": "https://<your-trips-function-app>.azurewebsites.net/api/trips?code=<your code>",
"passengerCode": "[email protected]",
"passengerFirstName": "Kurt",
"passengerLastName": "Ramo",
"PassengerMobile": "505551515",
"PassengerEmail": "[email protected]",
"sourceLatitude": 28,
"sourceLongitude": 40,
"destinationLatitude": 33,
"destinationLongitude": 51
},
{
"url": "https://<your-trips-function-app>.azurewebsites.net/api/trips?code=<your code>",
"passengerCode": "[email protected]",
"passengerFirstName": "Smith",
"passengerLastName": "Jones",
"PassengerMobile": "50551102",
"PassengerEmail": "[email protected]",
"sourceLatitude": 31,
"sourceLongitude": 50,
"destinationLatitude": 32,
"destinationLongitude": 60
},
{
"url": "https://<your-trips-function-app>.azurewebsites.net/api/trips?code=<your code>",
"passengerCode": "[email protected]",
"passengerFirstName": "Rita",
"passengerLastName": "Ghana",
"PassengerMobile": "505556156",
"PassengerEmail": "[email protected]",
"sourceLatitude": 28,
"sourceLongitude": 40,
"destinationLatitude": 33,
"destinationLongitude": 51
}
]
Please note the following about the Seeder
test:
- Since the tester loads the test parameters from a URL, the test parameters can be varied independently without having to re-compile the code.
- Since each test parameter defines the URL to submit trip requests to, production and dev environments can be tested at the same time.
One way to verify that the test ran successfully is to query the trip summaries in the TripFact
table for the number of entries after the test runs:
SELECT * FROM dbo.TripFact
The number of entries should match the number of submitted trips. Let us say, for example, we started the test with the test parameters shown above: Seeder.exe url 2 60
. This means that the test will run for 2 iterations submitting 4 trips in each iteration. Therefore we expect to see 8 new entries in the TripFact
table.
The following is a sample tester output for 2 iterations:
Iteration 0 starting....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - submitted in 15.02846 seconds.
TestTripRunner - submitted in 18.5976287 seconds.
TestTripRunner - submitted in 11.6632886 seconds.
TestTripRunner - submitted in 17.1535626 seconds.
Thread 0 => Duration: 17.1535626 - Error:
Thread 1 => Duration: 11.6632886 - Error:
Thread 2 => Duration: 15.02846 - Error:
Thread 3 => Duration: 18.5976287 - Error:
All tasks are finished.
Iteration 0 completed
Delaying for 60 seconds before starting iteration 1....
Iteration 1 starting....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Url https://ridesharetripsfunctionappdev.azurewebsites.net/api/trips?code=rtTQCEXCzUvrw0l28oCfZjhxkIMDeIyQWWj2NFuLxYbld/OwGdZ9aA== started....
TestTripRunner - Simulate a little delay....
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - submitted in 1.3980593 seconds.
TestTripRunner - submitted in 1.2487726 seconds.
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - submitted in 1.3474113 seconds.
TestTripRunner - Passenger Code: [email protected] ....
TestTripRunner - submitted in 1.3841847 seconds.
Thread 0 => Duration: 1.2487726 - Error:
Thread 1 => Duration: 1.3980593 - Error:
Thread 2 => Duration: 1.3841847 - Error:
Thread 3 => Duration: 1.3474113 - Error:
All tasks are finished.
Iteration 1 completed
Test is completed. Press any key to exit...
Application Insights and Azure Dashboard are great resources to monitor a solution in production. One can pin response time, requests and failure requests from the solution Application Insights resource right into the Azure Dashboard:
In addition, one can also create custom queries and pin their results to the dashboard as well. For example, the following is an analytic query that shows the distribution of custom events (sent from the code) in the last 24 hours:
customEvents
| where timestamp > ago(24h)
| summarize count() by name
| render piechart
The result shows the distribution of a trip during different stages:
Custom metrics are sent from the solution to the Application Insights resources to denote a metric value. In fact, if an Application Insights is attached to a Function App, the Azure Functions framework automatically sends Count
, AvgDurationMs
, MaxDurationMs
, MinDurationMs
, Failures
, Successes
and SuccessRate
custom metrics for each function i.e. trigger, orchestrator or activity.
The following is an analytic query that shows in a pie chart the occurrences of the following two custom metrics in the last 24 hours: Active trips
and O_MonitorTrip
:
customMetrics
| where timestamp > ago(24h)
| where name == "Active trips" or name contains "O_MonitorTrip"
| summarize count() by name
| render piechart
The result shows the distribution of the above 2 custom metrics:
The Rideshare Azure Function Apps have been configured to use Application Insights automatically by supplying the Application Insights instrumentation key to the APPINSIGHTS_INSTRUMENTATIONKEY
app setting. This makes it trivial to integrate App Insights, because the functions within your Function App will transparently send log messages, exceptions, and telemetry data to App Insights for you. While this makes integrating the two services a simple task, there are some benefits that can be gained from manually using Application Insights using the available SDKs. Chief among these benefits is telemetry correlation. Every operation, or request, within a microservices architecture such as this can generate telemetry data in Application Insights. When this activity is logged, it is associated with a unique field called the operation_id
. This id
is associated with all telemetry (traces, exceptions, etc.) that is part of a request. However, there are usually several services involved in a request pipeline. In the case of the Rideshare architecture, a request pipeline can involve an Azure function with an HTTP trigger that handles a new ride request from a customer, which then uses several other functions to orchestrate the trip activity. These microservices will be assigned their own operation_id
in App Insights. However, it is easy to lose track of how these various service activities relate over a period of time from the start of the trip request transaction to the end. Especially when there is a lot of trip activity due to high customer demand (hopefully!). One way to resolve this is to correlate all of these activities together for a given request pipeline (such as a trip request). You can do this by passing the initial operation_id
to each service so it can be stored as the operation_parentId
. This information, along with request telemetry and dependency telemetry can help you relate, or correlate, all of the related activities from your microservices together in a way that helps you trace activity throughout your architecture and have a better understanding of how your request pipelines are behaving.
Read more about using Application Insights to monitor Azure Functions and track custom telemetry.
Using Application Insights is a great way for developers and operations to monitor raw telemetry and overall service health, especially when using a combination of real-time views like Live Metrics Stream and built-in dashboards. These are views that provide crucial information about technical aspects of the application, but generally do not clearly show the overall state of the data and business metrics, such as how many trips were completed vs. how many aborted, top drivers by number of trips, geographic areas with the most trips, etc.
The Power BI within our solution is configured to save this data to Azure SQL database for simple consumption from Power BI for analyzing snapshots of business metrics. Alternatively, you can use this handler to send trips to a streaming Power BI dataset to display the data in real-time. In this way, you will have real-time monitoring that is tuned for developers/operations, and for business users.