├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── index.d.ts ├── index.js ├── index.ts ├── lib ├── index.ts ├── interfaces │ └── redisLockOptions.interface.ts ├── redisLock.constants.ts ├── redisLock.decorator.ts ├── redisLock.module.ts └── redisLock.service.ts ├── package.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @huangang/nestjs-simple-redis-lock 2 | Distributed lock with single redis instance, simple and easy to use for [Nestjs](https://github.com/nestjs/nest) 3 | 4 | ## Installation 5 | ``` 6 | npm install @huangang/nestjs-simple-redis-lock 7 | ``` 8 | 9 | ## Usage 10 | You must install [@liaoliaots/nestjs-redis](https://github.com/liaoliaots/nestjs-redis), and use in Nest. This package use it to access redis: 11 | ```JavaScript 12 | // app.ts 13 | import { RedisLockModule } from '@huangang/nestjs-simple-redis-lock'; 14 | import { RedisModule, RedisManager } from '@liaoliaots/nestjs-redis'; 15 | 16 | @Module({ 17 | imports: [ 18 | ... 19 | RedisModule.forRootAsync({ // import RedisModule before RedisLockModule 20 | imports: [ConfigModule], 21 | useFactory: (config: ConfigService) => ({ 22 | host: config.get('REDIS_HOST'), 23 | port: config.get('REDIS_PORT'), 24 | db: parseInt(config.get('REDIS_DB'), 10), 25 | password: config.get('REDIS_PASSWORD'), 26 | keyPrefix: config.get('REDIS_KEY_PREFIX'), 27 | }), 28 | inject: [ConfigService], 29 | }), 30 | RedisLockModule.registerAsync({ 31 | useFactory: async (redisManager: RedisManager) => { 32 | return { prefix: ':lock:', client: redisManager.getClient() } 33 | }, 34 | inject: [RedisManager] 35 | }), // import RedisLockModule, use default configuration 36 | ] 37 | }) 38 | export class AppModule {} 39 | ``` 40 | ### 1. Simple example 41 | ```TypeScript 42 | import { RedisLockService } from '@huangang/nestjs-simple-redis-lock'; 43 | 44 | export class FooService { 45 | constructor( 46 | protected readonly lockService: RedisLockService, // inject RedisLockService 47 | ) {} 48 | 49 | async test1() { 50 | try { 51 | /** 52 | * Get a lock by name 53 | * Automatically unlock after 1min 54 | * Try again after 100ms 55 | * The max times to retry is 36000, about 1h 56 | */ 57 | await this.lockService.lock('test1'); 58 | // Do somethings 59 | } finally { // use 'finally' to ensure unlocking 60 | this.lockService.unlock('test1'); // unlock 61 | // Or: await this.lockService.unlock('test1'); wait for the unlocking 62 | } 63 | } 64 | 65 | async test2() { 66 | /** 67 | * Automatically unlock after 2min 68 | * Try again after 50ms if failed 69 | * The max times to retry is 100 70 | */ 71 | await this.lockService.lock('test1', 2 * 60 * 1000, 50, 100); 72 | // Do somethings 73 | await this.lockService.setTTL('test1', 60000); // Renewal the lock when the program is very time consuming, avoiding automatically unlock 74 | this.lockService.unlock('test1'); 75 | } 76 | } 77 | ``` 78 | 79 | ### 2. Example by using decorator 80 | Using `@huangang/nestjs-simple-redis-lock` by decorator, the locking and unlocking will be very easy. 81 | Simple example with constant lock name: 82 | ```TypeScript 83 | import { RedisLockService, RedisLock } from '@huangang/nestjs-simple-redis-lock'; 84 | 85 | export class FooService { 86 | constructor( 87 | protected readonly lockService: RedisLockService, // inject RedisLockService 88 | ) {} 89 | 90 | /** 91 | * Wrap the method, starting with getting a lock, ending with unlocking 92 | * The first parameter is lock name 93 | * By default, automatically unlock after 1min. 94 | * By default, try again after 100ms if failed 95 | * By default, the max times to retry is 36000, about 1h 96 | */ 97 | @RedisLock('test2') 98 | async test1() { 99 | // Do somethings 100 | return 'some values'; 101 | } 102 | 103 | /** 104 | * Automatically unlock after 2min 105 | * Try again after 50ms if failed 106 | * The max times to retry is 100 107 | */ 108 | @RedisLock('test2', 2 * 60 * 1000, 50, 100) 109 | async test2() { 110 | // Do somethings 111 | return 'some values'; 112 | } 113 | } 114 | ``` 115 | 116 | The first parameter of this decorator is a powerful function. It can use to determinate lock name by many ways. 117 | Simple example with dynamic lock name: 118 | ```TypeScript 119 | import { RedisLockService, RedisLock } from '@huangang/nestjs-simple-redis-lock'; 120 | 121 | export class FooService { 122 | lockName = 'test3'; 123 | 124 | constructor( 125 | protected readonly lockService: RedisLockService, // inject RedisLockService 126 | ) {} 127 | 128 | /** 129 | * Determinate lock name from 'this' 130 | * The first parameter is 'this', so you can access any member in 'this' for create a dynamic lock name. 131 | */ 132 | @RedisLock((target) => target.lockName) 133 | async test1() { 134 | // Do somethings 135 | return 'some values'; 136 | } 137 | 138 | /** 139 | * Determinate lock name from the parameters of the method 140 | * The original parameters also pass to the function, so you can determinate the lock name by the parameters. 141 | */ 142 | @RedisLock((target, param1, param2) => param1 + param2) 143 | async test2(param1, param2) { 144 | // Do somethings 145 | return 'some values'; 146 | } 147 | } 148 | ``` 149 | 150 | ## Configuration 151 | * Register:* 152 | ```TypeScript 153 | @Module({ 154 | imports: [ 155 | RedisLockModule.register({ 156 | clientName: 'client_name', // the Redis client name in nestjs-redis, to use specific Redis client. Default to use default client 157 | prefix: 'my_lock:', // By default, the prefix is 'lock:' 158 | }) 159 | ] 160 | }) 161 | ``` 162 | *Async register:* 163 | ```TypeScript 164 | @Module({ 165 | imports: [ 166 | RedisLockModule.registerAsync({ 167 | imports: [ConfigModule], 168 | useFactory: async (config: ConfigService) => ({ 169 | clientName: config.get('REDIS_LOCK_CLIENT_NAME') 170 | }), 171 | inject: [ConfigService], 172 | }), 173 | ] 174 | }) 175 | ``` 176 | 177 | ## Debug 178 | Add a environment variable `DEBUG=nestjs-simple-redis-lock` when start application to check log: 179 | ```json 180 | // package.json 181 | { 182 | "scripts": { 183 | "start:dev": "DEBUG=nestjs-simple-redis-lock tsc-watch -p tsconfig.build.json --onSuccess \"node dist/main.js\"", 184 | } 185 | } 186 | ``` 187 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./dist"; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | function __export(m) { 3 | for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; 4 | } 5 | exports.__esModule = true; 6 | __export(require("./dist")); -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './dist'; 2 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './redisLock.module'; 2 | export * from './redisLock.decorator'; 3 | export * from './redisLock.service'; 4 | -------------------------------------------------------------------------------- /lib/interfaces/redisLockOptions.interface.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata, Type } from '@nestjs/common/interfaces'; 2 | import Redis from 'ioredis'; 3 | 4 | export interface RedisLockOptions { 5 | prefix?: string; 6 | client?: Redis; 7 | } 8 | 9 | export interface RedisLockOptionsFactory { 10 | createRedisLockOptions(): Promise | RedisLockOptions; 11 | } 12 | 13 | export interface RedisLockAsyncOptions extends Pick { 14 | useExisting?: Type; 15 | useClass?: Type; 16 | useFactory?: (...args: any[]) => Promise | RedisLockOptions; 17 | inject?: any[]; 18 | } 19 | -------------------------------------------------------------------------------- /lib/redisLock.constants.ts: -------------------------------------------------------------------------------- 1 | export const REDIS_LOCK_OPTIONS = 'REDIS_LOCK_OPTIONS'; 2 | -------------------------------------------------------------------------------- /lib/redisLock.decorator.ts: -------------------------------------------------------------------------------- 1 | import { RedisLockService } from "./redisLock.service"; 2 | 3 | interface GetLockNameFunc { 4 | (target: any, ...args): string; 5 | } 6 | 7 | /** 8 | * Wrap a method, starting with getting a lock, ending with unlocking 9 | * @param {string} name lock name 10 | * @param {number} [retryInterval] milliseconds, the interval to retry 11 | * @param {number} [maxRetryTimes] max times to retry 12 | */ 13 | export function RedisLock(lockName: String | GetLockNameFunc, expire?: number, retryInterval?: number, maxRetryTimes?: number) { 14 | return function (target, key, descriptor) { 15 | const value = descriptor.value; 16 | const getLockService = (that): RedisLockService => { 17 | let lockService: RedisLockService; 18 | for (let i in that) { 19 | if (that[i] instanceof RedisLockService) { 20 | lockService = that[i]; 21 | break; 22 | } 23 | } 24 | if (!lockService) { 25 | throw new Error('RedisLock: cannot find the instance of RedisLockService'); 26 | } 27 | return lockService; 28 | } 29 | descriptor.value = async function (...args) { 30 | const lockService = getLockService(this); 31 | let name: string; 32 | if (typeof lockName === 'string') { 33 | name = lockName; 34 | } else if (typeof lockName === 'function') { 35 | name = lockName(this, ...args); 36 | } 37 | try { 38 | await lockService.lock(name, expire, retryInterval, maxRetryTimes); 39 | return await value.call(this, ...args); 40 | } finally { 41 | lockService.unlock(name); 42 | } 43 | } 44 | return descriptor; 45 | } 46 | } -------------------------------------------------------------------------------- /lib/redisLock.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Provider } from '@nestjs/common'; 2 | import { RedisLockService } from './redisLock.service'; 3 | import { RedisLockOptions, RedisLockAsyncOptions, RedisLockOptionsFactory } from './interfaces/redisLockOptions.interface'; 4 | import { REDIS_LOCK_OPTIONS } from './redisLock.constants'; 5 | 6 | function createRedisLockProvider(options: RedisLockOptions): any[] { 7 | return [ { provide: REDIS_LOCK_OPTIONS, useValue: options || {} }]; 8 | } 9 | 10 | @Module({ 11 | imports: [], 12 | providers: [RedisLockService], 13 | exports: [RedisLockService], 14 | }) 15 | export class RedisLockModule { 16 | static register(options: RedisLockOptions): DynamicModule { 17 | return { 18 | module: RedisLockModule, 19 | providers: createRedisLockProvider(options), 20 | }; 21 | } 22 | 23 | static registerAsync(options: RedisLockAsyncOptions): DynamicModule { 24 | return { 25 | module: RedisLockModule, 26 | imports: options.imports || [], 27 | providers: this.createAsyncProviders(options), 28 | }; 29 | } 30 | 31 | private static createAsyncProviders(options: RedisLockAsyncOptions): Provider[] { 32 | if (options.useExisting || options.useFactory) { 33 | return [this.createAsyncOptionsProvider(options)]; 34 | } 35 | return [ 36 | this.createAsyncOptionsProvider(options), 37 | { 38 | provide: options.useClass, 39 | useClass: options.useClass, 40 | } 41 | ] 42 | } 43 | 44 | private static createAsyncOptionsProvider(options: RedisLockAsyncOptions): Provider { 45 | if (options.useFactory) { 46 | return { 47 | provide: REDIS_LOCK_OPTIONS, 48 | useFactory: options.useFactory, 49 | inject: options.inject || [], 50 | }; 51 | } 52 | return { 53 | provide: REDIS_LOCK_OPTIONS, 54 | useFactory: async (optionsFactory: RedisLockOptionsFactory) => await optionsFactory.createRedisLockOptions(), 55 | inject: [options.useExisting || options.useClass], 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/redisLock.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from '@nestjs/common'; 2 | import * as debugFactory from 'debug'; 3 | import { REDIS_LOCK_OPTIONS } from './redisLock.constants'; 4 | import Redis from 'ioredis'; 5 | import { RedisLockOptions } from './interfaces/redisLockOptions.interface'; 6 | 7 | const debug = debugFactory('nestjs-simple-redis-lock'); 8 | debug('booting %o', 'nestjs-simple-redis-lock'); 9 | 10 | @Injectable() 11 | export class RedisLockService { 12 | public readonly uuid: string = RedisLockService.generateUuid(); 13 | 14 | constructor( 15 | @Inject(REDIS_LOCK_OPTIONS) protected readonly config: RedisLockOptions, 16 | ) { 17 | debug(`RedisLock: uuid: ${this.uuid}`); 18 | } 19 | 20 | private prefix(name: string): string { 21 | if (this.config.prefix) { 22 | return this.config.prefix + name; 23 | } 24 | return `lock:${name}`; 25 | } 26 | 27 | private getClient(): Redis { 28 | return this.config.client 29 | } 30 | 31 | /** 32 | * Generate a uuid for identify each distributed node 33 | */ 34 | private static generateUuid(): string { 35 | let d = Date.now(); 36 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c: String) => { 37 | const r = (d + Math.random() * 16) % 16 | 0; 38 | d = Math.floor(d / 16); 39 | return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); 40 | }); 41 | } 42 | 43 | /** 44 | * Try to lock once 45 | * @param {string} name lock name 46 | * @param {number} [expire] milliseconds, TTL for the redis key 47 | * @returns {boolean} true: success, false: failed 48 | */ 49 | public async lockOnce(name, expire) { 50 | const client = this.getClient(); 51 | const result = await client.set(this.prefix(name), this.uuid, 'PX', expire, 'NX'); 52 | debug(`lock: ${name}, result: ${result}`); 53 | return result !== null; 54 | } 55 | 56 | /** 57 | * Get a lock, automatically retrying if failed 58 | * @param {string} name lock name 59 | * @param {number} [retryInterval] milliseconds, the interval to retry if failed 60 | * @param {number} [maxRetryTimes] max times to retry 61 | */ 62 | public async lock(name: string, expire: number = 60000, retryInterval: number = 100, maxRetryTimes: number = 36000): Promise { 63 | let retryTimes = 0; 64 | while (true) { 65 | if (await this.lockOnce(name, expire)) { 66 | break; 67 | } else { 68 | await this.sleep(retryInterval); 69 | if (retryTimes >= maxRetryTimes) { 70 | throw new Error(`RedisLockService: locking ${name} timed out`); 71 | } 72 | retryTimes++; 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * Unlock a lock by name 79 | * @param {string} name lock name 80 | */ 81 | public async unlock(name) { 82 | const client = this.getClient(); 83 | const result = await client.eval( 84 | "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 85 | 1, 86 | this.prefix(name), 87 | this.uuid, 88 | ); 89 | debug(`unlock: ${name}, result: ${result}`); 90 | } 91 | 92 | /** 93 | * Set TTL for a lock 94 | * @param {string} name lock name 95 | * @param {number} milliseconds TTL 96 | */ 97 | public async setTTL(name, milliseconds) { 98 | const client = this.getClient(); 99 | const result = await client.pexpire(this.prefix(name), milliseconds); 100 | debug(`set TTL: ${name}, result: ${result}`); 101 | } 102 | 103 | /** 104 | * @param {number} ms milliseconds, the sleep interval 105 | */ 106 | public sleep(ms: Number): Promise { 107 | return new Promise(resolve => setTimeout(resolve, Number(ms))); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@huangang/nestjs-simple-redis-lock", 3 | "version": "0.7.1", 4 | "description": "Distributed lock with single redis instance, simple and easy to use for Nestjs", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rm -rf dist && tsc -p tsconfig.json", 8 | "prepublish": "npm run build", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [ 12 | "nestjs", 13 | "redis", 14 | "redis lock", 15 | "distributed lock" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+ssh://git@github.com/huangang/nestjs-simple-redis-lock.git" 20 | }, 21 | "author": "Alex Chen", 22 | "license": "MIT", 23 | "homepage": "https://github.com/huangang/nestjs-simple-redis-lock#readme", 24 | "dependencies": { 25 | "debug": "^4.3.4", 26 | "ioredis": "^5.3.2", 27 | "rxjs": "^7.8.1" 28 | }, 29 | "peerDependencies": { 30 | "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", 31 | "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" 32 | }, 33 | "devDependencies": { 34 | "@nestjs/common": "^9.4.3", 35 | "@nestjs/core": "^9.4.3", 36 | "@types/debug": "^4.1.12", 37 | "@types/node": "^16.18.82", 38 | "typescript": "^4.9.5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "include": [ 15 | "lib/**/*" 16 | ], 17 | "exclude": ["node_modules"] 18 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } --------------------------------------------------------------------------------