preface
Thanks to Node.js, front-end engineers can get their hands on back-end development cheaply. Egg.js makes it easier for developers to use Node.js to develop Restful services.
Nowadays, many people would think that it would be extremely easy to develop an API based on egg.js, just implement the Controller according to the specification, and implement a Service for the Controller to call if necessary. Then map the request path and request method to the Controller on the Router. But is it really that simple? Let’s follow Xiaobai through an API implementation journey that continuously adds requirements.
Special note: this article does not focus on the complete construction of an API service, the request authentication process will not be described here, the validity of parameters will not be expanded in detail, and the competition of the request will be ignored for the time being
Little White’s journey to realization
After learning how to use Egg to develop HTTP interface, Xiao Bai was eager to try and showed his supervisor that he could receive some HTTP service development requirements. In order not to hit Xiaobai’s enthusiasm, the supervisor thought about it and suddenly had an idea: Xiaobai, you can realize a text storage service. We often have some data to store in the front end, and we don’t want to need the back end to support each time.
Demand analysis
For this requirement, Xiaobai analyzed and formed the following use cases:
- As a front-end developer, I can call an API and pass in a code and a string of text content that I want to store in order to implement data storage
- As a front-end developer, I can call an API, pass in a code, and retrieve the stored text content for data reading
Technical solution V1.0
Where do you store your data? He had an idea. If there is a globally shared window object on the Web side, what about in the Egg? I went through the files and found
Application is a global Application object. Within an Application, only one is instantiated. It inherits from KOa. Application, on which we can mount global methods and objects. Application objects can be easily extended in plug-ins or applications.
Therefore, just extend a cache object on top of the Application object, store the code passed in as the key and the text as the value, and it is very convenient to read.
Code implementation V1.0
Initialize the
Bai is used to TypeScript coding, so he initializes the project using the initialization commands provided in the Egg documentation
mkdir store && cd store
npm init egg --type=ts
npm i
npm run dev
Copy the code
Extended cache object
Create app/extend/application. The ts file, follow the official written, in CTX. App object extension cache attribute, and add some auxiliary methods
const cache: {
[propName: string] :any;
} = {};
export default {
cache,
// Assemble the data format returned successfully
successResponse(data?) {
return {
result: 'success',
data
};
},
// Assemble the data format returned in error
errorResponse(error) {
return {
result: 'failed',
errCode: typeof error === 'string' ? 500 : error.code || '500',
message: typeof error === 'string' ? error : error.message || 'Server error'}; }};Copy the code
Controller
Create app/ Controller /store.ts and implement save and get methods
import { Controller } from 'egg';
export default class extends Controller {
public async save() {
const { ctx } = this;
const { key, value } = ctx.request.body;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
ctx.app.cache[key] = value;
ctx.body = ctx.app.successResponse();
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}public async get() {
const { ctx } = this;
const { key } = ctx.query;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
ctx.body = ctx.app.successResponse(ctx.app.cache[key]);
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}}Copy the code
Router
In app/router.ts, add a route
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router } = app;
// Demo route, which can be ignored
router.get('/', controller.home.index);
// store
router.post('/store', controller.store.save);
router.get('/store', controller.store.get);
};
Copy the code
“Well done, I also considered the exception capture, general function extraction.” Xiao Bai was very satisfied with himself. There was no problem with PostMan test, and he happily went to the supervisor. The director asked the implementation idea and asked a question: Xiao Bai, this seems to fulfill the requirements, but if your application restarts for some reason, will the data be lost?
Technical solution V2.0
The small white scratched his head and reflected, indeed he did not consider the formal use of the process will produce some problems. If you want to store it without losing it, you have to have a place to store it. Refer to the application run log to record data to a file, using the native FS FileAPI to do file reading and writing.
However, I don’t want to give up the cache method, which is the most efficient and can be used to store some temporary data, so I might as well extend the API to support multiple storage methods. The Controller receives an additional parameter type, which is file by default. The mode of data storage and reading is changed to file. When the user sends cache, the mode of memory reading and writing is used.
Code implementation V2.0
Service
Encapsulate the method of reading and storing data from files in app/service/file.ts
import { Service } from 'egg';
import * as fs from 'fs';
import * as path from 'path';
export default class extends Service {
// File storage path
private FILE_PATH = './app/file/cache.js';
public writeFile(filePath, fileData) {
return new Promise((resolve, reject) = > {
const writeStream = fs.createWriteStream(filePath);
writeStream.on('open'.(a)= > {
const blockSize = 128;
const nbBlocks = Math.ceil(fileData.length / blockSize);
for (let i = 0; i < nbBlocks; i += 1) {
const currentBlock = fileData.slice(
blockSize * i,
Math.min(blockSize * (i + 1), fileData.length)
);
writeStream.write(currentBlock);
}
writeStream.end();
});
writeStream.on('error'.err= > {
reject(err);
});
writeStream.on('finish'.(a)= > {
resolve(true);
});
});
}
public readFile(filePath): Promise<string> {
return new Promise((resolve, reject) = > {
const readStream = fs.createReadStream(filePath);
let data = ' ';
readStream.on('data'.chunk= > {
data += chunk;
});
readStream.on('end'.(a)= > {
resolve(data ? data.toString() : JSON.stringify({}));
});
readStream.on('error'.err= > {
reject(err);
});
});
}
public async save(key: string, value) {
const data: string = await this.readFile(path.resolve(this.FILE_PATH));
const jsonData = JSON.parse(data);
jsonData[key] = value;
await this.writeFile(
path.resolve(this.FILE_PATH),
new Buffer(JSON.stringify(jsonData))
);
return true;
}
public async get(key: string) {
const data: string = await this.readFile(path.resolve(this.FILE_PATH));
const jsonData = JSON.parse(data);
returnjsonData[key]; }}Copy the code
Controller
App/Controller /store.ts calls different implementations by determining type
import { Controller } from 'egg';
export default class extends Controller {
public async save() {
const { ctx } = this;
const { key, value, type = 'file' } = ctx.request.body;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
switch (type) {
case 'file':
await ctx.service.file.save(key, value);
break;
default:
ctx.app.cache[key] = value;
break;
}
ctx.body = ctx.app.successResponse();
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}public async get() {
const { ctx } = this;
const { key, type = 'file' } = ctx.query;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
let data;
switch (type) {
case 'file':
data = await ctx.service.file.get(key);
break;
default:
data = ctx.app.cache[key];
break;
}
ctx.body = ctx.app.successResponse(data);
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}}Copy the code
“Considering the compatibility of the two modes, and can realize the demand, great” the little white heart elated, continue to find the supervisor acceptance. The supervisor looked at it, nodded, and ordered Xiaobai to deploy online and promote use within the project team. Everyone is very comfortable after using this service, xiaobai also has a sense of achievement.
But the good times did not last long, with the increase of the number of users, xiaobai slowly received some feedback, “the request speed is getting slower and slower, a little painful”. Xiaobai went to consult background students how to do, background students said the fastest way is to add instances, do a load balancing.
Xiao Bai acted quickly and deployed the same application on another machine, and asked the operation and maintenance students to help do a load balancing, thinking that the problem could be solved, but unexpectedly caused another big BUG: the interface to call storage succeeded, but the interface to call read could not get data.
Xiaobai soon found that the problem was caused by the file only existing on a single application, the data was stored in a scattered way, of course not. Then go to consult senior xiaoming big guy. When Xiao Ming heard this question, he smiled and said that he had stepped on the same pit, multi-instance deployment of storage sharing, suggested using database or cache to solve the problem.
Technical solution V2.1
Haku decided to extend the API from 2.0 to support more storage modes. The original approach is retained because the single-server deployment of the service can also meet certain requirements, and sometimes storage tools such as mysql and Redis do not necessarily have them.
Code to achieve V2.1
Service
App /service/mysql. Ts and app/service/redis. Ts
Controller
Modified app/ Controller/Store. ts to add type determination. In order to avoid the need to modify the original code, the default type is changed to the more general mysql
import { Controller } from 'egg';
export default class extends Controller {
public async save() {
const { ctx } = this;
const { key, value, type = 'mysql' } = ctx.request.body;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
switch (type) {
case 'file':
await ctx.service.file.save(key, value);
break;
case 'mysql':
await ctx.service.mysql.save(key, value);
break;
case 'redis':
await ctx.service.redis.save(key, value);
break;
default:
ctx.app.cache[key] = value;
break;
}
ctx.body = ctx.app.successResponse();
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}public async get() {
const { ctx } = this;
const { key, type = 'cache' } = ctx.query;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
let data;
switch (type) {
case 'file':
data = await ctx.service.file.get(key);
break;
case 'mysql':
data = await ctx.service.mysql.get(key);
break;
case 'redis':
data = await ctx.service.redis.get(key);
break;
default:
data = ctx.app.cache[key];
break;
}
ctx.body = ctx.app.successResponse(data);
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}}Copy the code
Xiaobai will deploy V2.1 online, successfully solved everyone’s problem, in the peak business extension instances also become more convenient.
After some time, Bai received a special request: there was a project that wanted to call this interface to transfer data to their back end, and at the same time, retrieve data from the back end.
Small white wonder: why don’t you directly butt end, through me here transfer? “Because we have no external API at the back end, and we have been in stable operation with your service for a period of time, so we are relatively relieved.”
Xiaobai accepted, after all, their services are used by a lot of people, but also a sense of achievement.
Technical solution V3.0
Along the same lines, just add projectA. Ts as A Service to communicate with the projectA backend and call it in the Controller based on type.
When xiao Bai was anxious to start, he thought again that if more systems needed different storage methods in the future, the switch(type) part of the Controller would become more and more bloated, and he did not like this method.
Technical solution V3.1
Small white do not have what good recruit, have to go to consult old driver xiao Ming. Ming looked at the code, smiled and said, “You can think and try OCP and DIP.” He went back to work. In line with the trust of Xiao Ming, Xiao Bai quickly turned the book to find information, reviewed the SOLID design principles.
OCP, the open closed principle, refers to open for extension, closed for modification.
Xiao Bai analyzed his own program: for adding new storage methods, because different business logic is extracted into the Service, so it meets the principle of open to extension; However, for Controller, its responsibility should be to control the business process, and adding a new storage mode does not affect the business process. In fact, it should not be modified to its code, so it does not meet the principle of closing on changes.
DIP, dependency inversion principle, upper modules should not depend on lower modules, they should all depend on abstraction; Abstraction should not depend on details, details should depend on abstractions
In his own program, Controller belongs to the upper module and Service belongs to the lower module. The upper module directly depends on the lower module, so when the lower module changes or expands, the upper module will also be forced to make some adjustments, so it does not meet the dependency inversion principle.
To keep the Controller stable, all services need to be abstracted so that the Controller doesn’t care about the details. Fortunately, the save and get methods were defined when we wrote the Service, so the Controller only needs to know about these two methods and hide the details. In TypeScript, we use interfaces
Code to achieve V3.1
interface
/service/store/cache. Ts; /service/store/cache. And the newly built/service/store/interface. Ts file, for writing interface
export interface IStore {
save: (key: string, value: string) = > Promise<Boolean>;
get: (key: string) = > Promise<String>;
}
Copy the code
Service
The next step is to modify the services to implement this interface. The following uses /service/store/cache.ts as an example
import { Service } from 'egg';
import { IStore } from './interface';
export default class extends Service implements IStore {
public async save(key: string, value) {
const { ctx } = this;
ctx.app.cache[key] = value;
return true;
}
public async get(key: string) {
const { ctx } = this;
returnctx.app.cache[key]; }}Copy the code
The next step is to transform the Controller. To keep the logic stable, we want the Controller not to depend on the specific Service, but only to know how to call the methods in the Service to implement the process. So let’s first extend the Application object to provide a method to determine the specific scenario and return the specific Service for the Controller to use.
This is where the Helper object of the Egg should be extended. For space, extend the Application directly here
Application
Modify the app/extend/application. Ts file
import { IStore } from '.. /service/store/interface';
import { Context } from 'egg';
const cache: {
[propName: string] :any;
} = {};
export default {
cache,
successResponse(data?) {
// ...
},
errorResponse(error) {
// ...
},
// Returns the implementation of StoreService, depending on the parameters
getStoreService(ctx: Context): IStore {
return ctx.service.store[
ctx.query.type || ctx.request.body.type || 'cache']; }};Copy the code
Controller
Finally, write stable Controller code
import { Controller } from 'egg';
export default class extends Controller {
public async save() {
const { ctx } = this;
const { key, value } = ctx.request.body;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
await ctx.app.getStoreService(ctx).save(key, value);
ctx.body = ctx.app.successResponse();
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}public async get() {
const { ctx } = this;
const { key } = ctx.query;
if(! key) {return (ctx.body = ctx.app.errorResponse('Please provide key argument'));
}
try {
const data = await ctx.app.getStoreService(ctx).get(key);
ctx.body = ctx.app.successResponse(data);
} catch(error) { ctx.logger.error(error); ctx.body = ctx.app.errorResponse(error); }}}Copy the code
When future extensions are needed, as long as the business process remains the same, you only need to add files to the Service and implement the IStore interface, truly changing only one place when adding requirements.
Xiao Bai was very satisfied with the work and showed it to Xiao Ming. Xiao Ming smiled and asked, “Do you know what design mode you used in it?”
Small white leng for a while, he did not think of this layer, just in accordance with the design principles of coding, and carefully looked at, revealing a smile: “So, I unconsciously used XX mode ah, as for what mode, I don’t tell you, you fine taste.”
Since then, small white set foot on the practice of the design principle of the upgrade of the road.
conclusion
In this article, we followed Xiaobai to realize a simple storage service from scratch, and in the process of constantly upgrading requirements, we iterated our code, and finally formed a relatively stable architecture that conforms to OCP and DIP, making expansion more flexible and ensuring the stability of the original business logic.
At any time, design principles are essential guidelines for writing code and designing architecture, while design patterns are the concrete implementation of design principles in different scenarios. We should focus on the tao rather than the art.
SOLID, worthy of every engineer savor, continuous practice.