I. Intro
We have an IoT system. The sensor’s data saved in dynamodb every minute. However, we can’t render chart every minutes if users want to show for a month or even more. We want to show hourly data to reduce the amount of items fetched from dynamodb. We want the UI to load it fast. Thus, we can’t put the filtering on server or even client. We want to make the data ready before any filtering. We can do this many ways:
- Performing a sidekiq job to run hourly on backoffice server. (1)
- Use AWS Eventbridge to schedule a lambda to run hourly. (2)
(1) and (2) they both can handle this task. But we have some concerns. We don’t want to manage more resources so we don’t go with sidekiq. Event we have backoffice server. But no reason to increase the workload like this. (2) Eventbride is good. But we’re concerned about data consistency. Every hour, querying data based on timestamp. It’s totally fine. But we want to reduce the latency, especially the last hour data. What if users want to see data at 45th minute? It doesn’t equal 60m to be one hour so the lambda not fired yet.
We have a better solution. That’s dynamo db stream. Any item created/updated will fire a lambda.
II. Code Practice
Okay! that’s too much about design. This post, I’d like to show the refactoring only. Below is my code to compute hourly data for lambda function. This is pure code, not refactored yet. The readability is not good.
import DynamoAdapter from "./dynamo_adapter.js";
export default class ComputeHourlyConsumption extends DynamoAdapter {
// Logical sensor ids
static TOTAL_CHARGE = 1
static TOTAL_DISCHARGE = 2
static CONSUMED_CHARGE = 3
static CONSUMED_DISCHARGE = 4
static CHARGE_REVENUE = 5
static DISCHARGE_REVENUE = 6
static CHARGE_PRICE = 0.1
static DISCHARGE_PRICE = 0.2
setChargePrice(price) {
this.chargePrice = price
}
setDischargePrice(price) {
this.dischargePrice = price
}
async call() {
const clientId = this.clientId.split("-")
switch (clientId[0]) {
case ComputeHourlyConsumption.TOTAL_CHARGE:
this._computeConsumption(ComputeHourlyConsumption.CONSUMED_CHARGE).then(item => {
this._computeRevenue(ComputeHourlyConsumption.CHARGE_REVENUE, item, this._getChargePrice())
})
break
case ComputeHourlyConsumption.TOTAL_DISCHARGE:
this._computeConsumption(ComputeHourlyConsumption.CONSUMED_DISCHARGE).then(item => {
this._computeRevenue(ComputeHourlyConsumption.DISCHARGE_REVENUE, item, this._getDischargePrice())
})
break
}
}
_getChargePrice() {
if (this.chargePrice === undefined || this.chargePrice === null) {
return ComputeHourlyConsumption.CHARGE_PRICE;
} else {
return this.chargePrice;
}
}
_getDischargePrice() {
if (this.dischargePrice === undefined || this.dischargePrice === null) {
return ComputeHourlyConsumption.DISCHARGE_PRICE
} else {
return this.dischargePrice;
}
}
async _computeConsumption(sensor_id) {
const currentHour = this.sampledTime
const prevHour = this.sampledTime - (this.sampledTime % 3600)
console.log(`last hour sampledTime: ${prevHour}`)
this._lastHourData(prevHour).then(prevItem => {
this.sampledTime = currentHour
const consumedVal = this.val - prevItem.val
new ComputeHourlyConsumption(this.table, this._consumedClientId(sensor_id),
currentHour, consumedVal, currentHour).put().then(item => {
return item
})
})
}
_computeRevenue(sensor_id, consumedItem, price) {
new ComputeHourlyConsumption(this.table, this._consumedClientId(sensor_id),
consumedItem.sampledTime, consumedItem.val * price, consumedItem.sampledTime).put().then(item => {
return item
})
}
_consumedClientId(sensor_id) {
return `${sensor_id}-${this.clientId.split("-")[1]}`;
}
_lastHourData = async (prevHour) => {
this.sampledTime = prevHour
return this.query();
}
}
There are many code smells.
- Not folllowed Single Responsibility completely.
- Call function is too messy.
- Code block {} too deep, hard to understand.
Should group variables for better understanding.
static SENSOR_IDS = {
total_charge: 1,
total_discharge: 2,
consumed_charge: 3,
consumed_discharge: 4,
charge_revenue: 5,
discharge_revenue: 6,
};
static DEFAULT_PRICES = {
charge: 0.1,
discharge: 0.2,
};
The call function. Level of code block is too deep. This is horizontal direction. It takes the result continuously to process. We want to make it vertically. That’s is a tradeoff. We have to create more variables.
async call() {
const clientId = this.clientId.split("-")
switch (clientId[0]) {
case ComputeHourlyConsumption.TOTAL_CHARGE:
this._computeConsumption(ComputeHourlyConsumption.CONSUMED_CHARGE).then(item => {
this._computeRevenue(ComputeHourlyConsumption.CHARGE_REVENUE, item, this._getChargePrice())
})
break
case ComputeHourlyConsumption.TOTAL_DISCHARGE:
this._computeConsumption(ComputeHourlyConsumption.CONSUMED_DISCHARGE).then(item => {
this._computeRevenue(ComputeHourlyConsumption.DISCHARGE_REVENUE, item, this._getDischargePrice())
})
break
}
}
Instead of one switch case statement we use if else statement at each smaller function for better SRP.
_getConsumedSensorId(sensorType) {
return sensorType === 'charge'
? ComputeConsumedSensor.SENSOR_IDS.consumed_charge
: ComputeConsumedSensor.SENSOR_IDS.consumed_discharge;
}
_getRevenueId(sensorType) {
return sensorType === 'charge'
? ComputeConsumedSensor.SENSOR_IDS.charge_revenue
: ComputeConsumedSensor.SENSOR_IDS.discharge_revenue;
}
_getPrice(sensorType) {
if (sensorType === 'charge') {
return this.chargePrice ?? ComputeConsumedSensor.DEFAULT_PRICES.charge;
} else {
return this.dischargePrice ?? ComputeConsumedSensor.DEFAULT_PRICES.discharge;
}
}
Same manner with this function.
async _computeConsumedSensor(sensorId) {
const prevHour = this.sampledTime - 3600;
const previousData = await this._getLastHourData(prevHour);
const consumedValue = this.val - previousData.val;
const consumedClientId = this._getConsumedClientId(sensorId);
return new ComputeConsumedSensor(
this.table,
consumedClientId,
this.sampledTime,
consumedValue,
this.sampledTime
).put();
}
Now, we have call function like this.
async call() {
const sensorType = this._getSensorType();
if (!sensorType) return;
const consumedSensorId = this._getConsumedSensorId(sensorType);
const revenueId = this._getRevenueId(sensorType);
const price = this._getPrice(sensorType);
const consumedSensor = await this._computeConsumedSensor(consumedSensorId);
await this._computeRevenue(revenueId, consumedSensor, price);
}
Full code.
import DynamoSensorAdapter from "../lib/adapters/dynamo_sensor_adapter.js";
export default class ComputeConsumedSensor extends DynamoSensorAdapter {
static SENSOR_IDS = {
total_charge: 1,
total_discharge: 2,
consumed_charge: 3,
consumed_discharge: 4,
charge_revenue: 5,
discharge_revenue: 6,
};
static DEFAULT_PRICES = {
charge: 0.1,
discharge: 0.2,
};
async call() {
const sensorType = this._getSensorType();
if (!sensorType) return;
const consumedSensorId = this._getConsumedSensorId(sensorType);
const revenueId = this._getRevenueId(sensorType);
const price = this._getPrice(sensorType);
const consumedSensor = await this._computeConsumedSensor(consumedSensorId);
await this._computeRevenue(revenueId, consumedSensor, price);
}
setChargePrice(price) {
this.chargePrice = price;
}
setDischargePrice(price) {
this.dischargePrice = price;
}
_getSensorType() {
const sensorID = this.clientId.split("-")[0];
if (sensorID === ComputeConsumedSensor.SENSOR_IDS.total_charge) {
return 'charge';
} else if (sensorID === ComputeConsumedSensor.SENSOR_IDS.total_discharge) {
return 'discharge';
}
return null;
}
_getConsumedSensorId(sensorType) {
return sensorType === 'charge'
? ComputeConsumedSensor.SENSOR_IDS.consumed_charge
: ComputeConsumedSensor.SENSOR_IDS.consumed_discharge;
}
_getRevenueId(sensorType) {
return sensorType === 'charge'
? ComputeConsumedSensor.SENSOR_IDS.charge_revenue
: ComputeConsumedSensor.SENSOR_IDS.discharge_revenue;
}
_getPrice(sensorType) {
if (sensorType === 'charge') {
return this.chargePrice ?? ComputeConsumedSensor.DEFAULT_PRICES.charge;
} else {
return this.dischargePrice ?? ComputeConsumedSensor.DEFAULT_PRICES.discharge;
}
}
async _computeConsumedSensor(sensorId) {
const prevHour = this.sampledTime - 3600;
const previousData = await this._getLastHourData(prevHour);
const consumedValue = this.val - previousData.val;
const consumedClientId = this._getConsumedClientId(sensorId);
return new ComputeConsumedSensor(
this.table,
consumedClientId,
this.sampledTime,
consumedValue,
this.sampledTime
).put();
}
async _computeRevenue(sensorId, consumedSensor, price) {
const consumedClientId = this._getConsumedClientId(sensorId);
const revenueValue = consumedSensor.val * price;
return new ComputeConsumedSensor(
this.table,
consumedClientId,
consumedSensor.sampledTime,
revenueValue,
consumedSensor.sampledTime
).put();
}
_getConsumedClientId(sensorId) {
const clientIdSuffix = this.clientId.split("-")[1];
return `${sensorId}-${clientIdSuffix}`;
}
async _getLastHourData(prevHour) {
this.sampledTime = prevHour;
return this.query();
}
}
Now, every function is clear and follow SRP.
III. Conclusion
In javascript, async and await come to solve the level too deep issue. Create many variables will impact the performance. It needs more memory to contains data. Garbage collector works more. But this case we want readability. The performance impact is not big, can be ignored. Software design always comes with a tradeoff.