24 KiB
order |
---|
1 |
Server-side Kernel
Microservices
To understand NocoBase faster, let's create an application with a new app.js file with the following code.
const { Application } = require('@nocobase/server');
const app = new Application({
// omit the configuration information
});
// configure a users table
app.collection({
name: 'users',
fields: [
{ type: 'string', name: 'username' },
{ type: 'password', name: 'password' }
],
});
// parse argv arguments, terminal does different operations via command line
app.parse(process.argv);
The terminal runs
# Generate database table structure based on configuration
node app.js db:sync
# Start the application
node app.js start --port=3000
The REST API for the relevant users table is generated
GET http://localhost:3000/api/users
POST http://localhost:3000/api/users
GET http://localhost:3000/api/users/1
PUT http://localhost:3000/api/users/1
DELETE http://localhost:3000/api/users/1
The above example creates a real working REST API service in just about 10 lines of code. In addition to the built-in REST API, you can also customize other actions such as login, registration, logout, etc. via ``app.actions()`.
app.actions({
async login(ctx, next) {},
async register(ctx, next) {},
async logout(ctx, next) {},
}, {
resourceName: 'users', // resource belonging to users
});
The HTTP API for the above custom operation is
POST http://localhost:3000/api/users:login
POST http://localhost:3000/api/users:register
POST http://localhost:3000/api/users:logout
The custom HTTP API remains in the style of the REST API, represented in the <resourceName>:<actionName>` format. In fact, the REST API can also explicitly specify
actionName, and when
actionName`` is specified, it does not matter what request method is used, e.g.
# Update actions
PUT http://localhost:3000/api/users/1
# is equivalent to
POST http://localhost:3000/api/users:update/1
# Delete operation
DELETE http://localhost:3000/api/users/1
# Equivalent to
GET http://localhost:3000/api/users:destroy/1
# Equivalent to
POST http://localhost:3000/api/users:destroy/1
NocoBase's Resourcer is designed based on Resource and Action, combining REST and RPC to provide a more flexible and unified Resource Action API. combined with the client SDK it looks like
const { ClientSDK } = require('@nocobase/sdk');
const api = new ClientSDK({
// can be adapted to different requests
request(params) => Promise.resolve({}),
});
await api.resource('users').list();
await api.resource('users').create();
await api.resource('users').get();
await api.resource('users').update();
await api.resource('users').destroy();
await api.resource('users').login();
await api.resource('users').register();
await api.resource('users').logout();
Application
NocoBase's Application inherits Koa, integrates with DB and CLI, adds some essential APIs, here are some highlights.
app.db
: database instance, each app has its own db.db.getCollection()
data table/datasetcollection.repository
data warehousecollection.model
data model
db.on()
Add event listener, provided by EventEmitterdb.exit()
Triggers an event, provided by EventEmitterdb.exitAsync()
Triggers an asynchronous event
app.cli
, the Commander instance, provides command-line operationsapp.context
, the contextctx.db
ctx.action
, the current resource operation instanceaction.params
operation parametersaction.mergeParams()
parameter merge method
app.constructor()
initializationapp.collection()
Define data Schema, equivalent toapp.db.collection()
app.resource()
Define resourcesapp.actions()
defines the resource's action methodsapp.on()
Add event listeners, provided by EventEmitterapp.exit()
Triggers an event, provided by the EventEmitterapp.exitAsync()
Triggers an asynchronous eventapp.use()
Add middleware, provided by Koaapp.command()
Custom command line, equivalent toapp.cli.command()
app.plugin()
Add pluginsapp.load()
Load configuration, mainly for loading pluginsapp.parse()
parse argv arguments, written at the end, equivalent toapp.cli.parseAsync()
Collection
NocoBase defines the Schema of the data through the app.collection()
method, the types of Schema include
Attribute
- Boolean Boolean
- String String
- Text long text
- Integer integer
- Float Floating-point
- Decimal Currency
- Json/Jsonb/Array Different database JSON types are not the same, there are compatibility problems
- Time Time
- Date
- Virtual Virtual fields
- Reference
- Formula Calculation formula
- Context Context
- Password Password
- Sort Sort
Relationships Association/Realtion
- HasOne One-to-One
- HasMany One-to-Many
- BelongsToMany
- BelongsToMany Many-to-many
- Polymorphic Polymorphism
For example, the table structure of a micro-blog can be designed like this
// users
app.collection({
name: 'users',
fields: {
username: { type: 'string', unique: true },
password: { type: 'password', unique: true },
posts: { type: 'hasMany' },
},
});
// Articles
app.collection({
name: 'posts',
fields: {
title: 'string',
content: 'text',
tags: 'believesToMany',
comments: 'hasMany',
author: { type: 'belongsTo', target: 'users' }
},
});
// Tags
app.collection({
name: 'tags',
fields: [
{ type: 'string', name: 'name' }
{ type: 'belongsToMany', name: 'posts' },
],
});
// Comments
app.collection({
name: 'comments',
fields: [
{ type: 'text', name: 'content' },
{ type: 'belongsTo', name: 'user' },
],
});
In addition to configuring the schema via app.collection()
, you can also directly call the api to insert or modify the schema, the core API of collection are
collection
The data structure of the current collectioncollection.hasField()
Determine if a field existscollection.addField()
Add a field configurationcollection.getField()
Get the field configurationcollection.removeField()
Remove a field configurationcollection.sync()
Synchronize with database table structure
collection.repository
The data repository for the current collectionrepository.findMany()
repository.findOne()
repository.create()
repository.update()
repository.destroy()
repository.relatedQuery().for()
create()
update()
destroy()
findMany()
findOne()
set()
add()
remove()
toggle()
collection.model
The data model of the current collection
Collection example.
const collection = app.db.getCollection('posts');
collection.hasField('title');
collection.getField('title');
// Add or update
collection.addField({
type: 'string',
name: 'content',
});
// Remove
collection.removeField('content');
// add, or specify a key path to replace
collection.mergeField({
name: 'content',
type: 'string',
});
In addition to the global `db.sync()`, there is also the `collection.sync()` method.
await collection.sync();
db:sync
is one of the very commonly used command lines to generate a table structure from the collection's schema. See the CLI section for more details. After db:sync
, you can write data to the table, either using Repository or Model operations.
- Repository initially provides findAll, findOne, create, update, destroy core operations.
- Model. See the Sequelize documentation for detailed instructions on how to use it.
- Model depends on the adapted ORM, Repository provides a unified interface based on Model.
Creating data via Repository
const User = app.db.getCollection('users');
const user = await User.repository.create({
title: 't1',
content: 'c1',
author: 1,
tags: [1,2,3],
}, {
whitelist: [],
blacklist: [],
});
await User.repository.findMany({
filter: {
title: 't1',
},
fields: ['id', 'title', 'content'],
sort: '-created_at',
page: 1,
perPage: 20,
});
await User.repository.findOne({
filter: {
title: 't1',
},
fields: ['id', 'title', 'content'],
sort: '-created_at',
page: 1,
perPage: 20,
});
await User.repository.update({
title: 't1',
content: 'c1',
author: 1,
tags: [1,2,3],
}, {
filter: {},
whitelist: [],
blacklist: [],
});
await User.repository.destroy({
filter: {},
});
Create data from Model
const User = db.getCollection('users');
const user = await User.model.create({
title: 't1',
content: 'c1',
});
Resource & Action
Resource is an Internet resource, and all Internet resources correspond to an address. In REST, the request method (GET/POST/PUT/DELETE) is used to identify the specific action, but the request method is rather limited, for example, the above mentioned login, registration and logout cannot be represented by REST API. To solve such problems, NocoBase represents resource actions in <resourceName>:<actionName>
format. In the world of the relational model, relationships are everywhere, and based on relationships, NocoBase extends the concept of relational resources, with actions corresponding to relational resources in the format <associatedName>. <resourceName>:<actionName>
.
The Collection is automatically synchronized to the Resource, as defined in the Schema in the Collection section above, and the resources that can be refined are
users
users.posts
posts
posts.tags
posts.comments
posts.author
tags
tags.posts
comments
comments.user
- Collection defines the schema (structure and relationships) of the data
- Resource defines the action of the data
- The data structure of the Resource request and response is defined by the Collection
- Collection is automatically synchronized to Resource by default
- The concept of Resource is much larger and can interface to external data or other customizations in addition to the Collection
Resource-related APIs are.
app.resource()
app.actions()
ctx.action
A resource can have multiple actions.
// Data classes
app.resource({
name: 'users',
actions: {
async list(ctx, next) {},
async get(ctx, next) {},
async create(ctx, next) {}, async create(ctx, next) {},
async update(ctx, next) {},
async destroy(ctx, next) {}, async destroy(ctx, next) {},
},
});
// Non-data classes
app.resource({
name: 'server',
actions: {
// Get the server time
getTime(ctx, next) {},
// Health check
healthCheck(ctx, next) {},
},
});
General operations can be used for different resources
app.actions({
async list(ctx, next) {},
async get(ctx, next) {},
async create(ctx, next) {},
async update(ctx, next) {},
async destroy(ctx, next) {},
}, {
// shared globally if resourceName is not specified
resourceNames: ['posts', 'comments', 'users'],
});
The action defined inside the resource will not be shared, regular operations like adding, deleting, changing and checking are recommended to be set as global, app.resource()
only set parameters, e.g.
app.resource({
name: 'users',
actions: {
list: {
fields: ['id', 'username'], // output only the id and username fields
filter: {
'username.$ne': 'admin', // data range filtering filter username ! = admin
},
sort: ['-created_at'], // reverse order of creation time
perPage: 50,
},
get: {
fields: ['id', 'username'], // output only the id and username fields
filter: {
'username.$ne': 'admin', // data range filtering filter username ! = admin
},
},
create: {
fields: ['username'], // whitelist
},
update: {
fields: ['username'], // whitelist
},
destroy: {
filter: { // cannot delete admin
'username.$ne': 'admin',
},
},
},
});
// The app has built-in list, get, create, update, destroy operations by default
app.actions({
async list(ctx, next) {},
async get(ctx, next) {},
async create(ctx, next) {}, async create(ctx, next) {},
async update(ctx, next) {},
async destroy(ctx, next) {}, async destroy(ctx, next) {},
});
In both the Middleware Handler and the Action Handler, the current action instance is available via ctx.action
, providing two very useful APIs.
ctx.action.params
: Get the parameters of the actionctx.action.mergeParams()
: handles merging of parameters from multiple sources
ctx.action.params
has.
- Locate resources and actions
actionName
resourceName
associatedName
- Locate the resource ID
resourceId
associatedId
- request query
filter
fields
sort
page
perPage
- Other query values
- request body
values
Example.
async function (ctx, next) {
const { resourceName, resourceId, filter, fields } = ctx.action.params;
// ...
}
ctx.action.mergeParams()
is mainly used for merging multi-source parameters, using the filter
parameter as an example. E.g., client request for articles created on 2021-09-15
GET /api/posts:list?filter={"created_at": "2021-09-15"}
Resource settings are locked to view only published posts
app.resource({
name: 'posts',
actions: {
list: {
filter: { status: 'publish' }, // Only view published posts
},
},
})
Permissions to view only articles you have created
app.use(async (ctx, next) => {
const { resourceName, actionName } = ctx.action.params;
if (resourceName === 'posts' && actionName === 'list') {
ctx.action.mergeParams({
filter: {
created_by_id: ctx.state.currentUser.id,
},
});
}
await next();
});
We specify filter parameters within the client, resource configuration, and middleware above, and the parameters from the three sources will eventually be merged together as the final filter condition: `
async function list(ctx, next) {
// The filter obtained in the list operation
console.log(ctx.params.filter);
// filter is a special and merge
// {
// and: [
// { created_at: '2021-09-15' }
// { status: 'published' },
// { created_by_id: 1, }
// ]
// }
}
Event
Event listeners are placed before and after the execution of an action, and can be added via app.db.on()
and app.on()
. The difference is that
app.db.on()
adds a database level listenerapp.on()
adds a listener at the server application level
Take users:login
as an example, it is a query
operation in the database and a login
operation in the application. In other words, if you need to log the login operation, you have to handle it in app.on()
.
// Triggered when User.create() is executed when creating data
app.db.on('users.beforeCreate', async (model) => {});
// Triggered when the client `POST /api/users:login`
app.on('users.beforeLogin', async (ctx, next) => {});
// Triggered when the client `POST /api/users`
app.on('users.beforeCreate', async (ctx, next) => {});
Middleware
Server Application is based on Koa, all Koa plugins (middleware) can be used directly and can be added via app.use()
. For example
const responseTime = require('koa-response-time');
app.use(responseTime());
app.use(async (ctx, next) => {
await next();
});
Slightly different from koa.use(middleware)
, app.use(middleware, options)
has an additional options parameter that can be used to qualify the resource and action, as well as to control where the middleware is inserted.
import { middleware } from '@nocobase/server';
app.use(async (ctx, next) => {}, {
name: 'middlewareName1',
resourceNames: [], // acts on all actions within the resource
actionNames: [
'list', // all list actions
'users:list', // List action for users resource only,
],
insertBefore: '',
insertAfter: '',
});
CLI
Application can be a CLI (with built-in Commander) in addition to being an HTTP Server. The current built-in commands are
init
initializedb:sync --force
to configure synchronization with the database table structurestart --port
to start the applicationplugin:**
Plugin-related
Customization.
app.command('foo').action(async () => {
console.log('foo...') ;
});
Plugin
Above, the core extension interfaces are described, including but not limited to.
- Database/Collection
app.db
database instanceapp.collection()
is equivalent toapp.db.collection()
- Resource/Action
app.resource()
is the same asapp.resourcer.define()
app.actions()
is the same asapp.resourcer.registerActions()
- Hook/Event
app.on()
Add a server listenerapp.db.on()
Add a database listener
- Middleware
app.use()
Add middleware
- CLI
app.cli
commander instanceapp.command()
is equivalent toapp.cli.command()
Based on the above extension interface, further modular and pluggable plugins are provided, which can be added via app.plugin()
. The process of plugins includes install, upgrade, activate, load, disable, uninstall, and the unwanted process can be missing. For example.
the simplest plugin
app.plugin(function pluginName1() {
});
Plugins added in this way will load directly, no installation required.
JSON style
const plugin = app.plugin({
enable: false, // default is true, you can disable it if you don't need to enable it.
name: 'plugin-name1',
displayName: 'plugin-name',
version: '1.2.3',
dependencies: {
pluginName2: '1.x',
pluginName3: '1.x',
},
async install() {},
async upgrade() {},
async activate() {},
async bootstrap() {},
async deactivate() {},
async unstall() {},
});
// Activate the plugin via the api
plugin.activate();
OOP style
class MyPlugin extends Plugin {
async install() {}
async upgrade() {}
async bootstrap() {}
async activate() {}
async deactivate() {}
async unstall() {}
}
app.plugin(MyPlugin);
// or
app.plugin({
name: 'plugin-name1',
displayName: 'plugin-name',
version: '1.2.3',
dependencies: {
pluginName2: '1.x',
pluginName3: '1.x',
},
plugin: MyPlugin,
});
Reference to a separate Package
app.plugin('@nocobase/plugin-action-logs');
Plugin information can also be written directly in package.json
{
name: 'pluginName1',
displayName: 'pluginName',
version: '1.2.3',
dependencies: {
pluginName2: '1.x',
pluginName3: '1.x',
},
}
Plugins CLI
plugin:install pluginName1
plugin:unstall pluginName1
plugin:activate pluginName1
plugin:deactivate pluginName1
Currently available plugins.
- @nocobase/plugin-collections provides data table configuration interface to manage data tables via HTTP API.
- @nocobase/plugin-action-logs Action logs
- @nocobase/plugin-automations Automation (not upgraded to v0.5, not available yet)
- @nocobase/plugin-china-region China Administrative Region
- @nocobase/plugin-client provides a client-side, codeless visual configuration interface that needs to be used in conjunction with @nocobase/client
- @nocobase/plugin-export exports
- @nocobase/plugin-file-manager File manager
- @nocobase/plugin-permissions Roles and permissions
- @nocobase/plugin-system-settings System configuration
- @nocobase/plugin-ui-router Front-end routing configuration
- @nocobase/plugin-ui-schema ui configuration
- @nocobase/plugin-users user module
Testing
If you have code, you need to test it. @nocobase/test provides mockDatabase and mockServer for database and server testing, e.g.
import { mockServer, MockServer } from '@nocobase/test';
describe('mock server', () => {
let api: MockServer;
beforeEach(() => {
api = mockServer({
dataWrapping: false,
});
api.actions({
list: async (ctx, next) => {
ctx.body = [1, 2];
await next();
},
});
api.resource({
name: 'test',
});
});
afterEach(async () => {
return api.destroy();
});
it('agent.get', async () => {
const response = await api.agent().get('/test');
expect(response.body).toEqual([1, 2]);
});
it('agent.resource', async () => {
const response = await api.agent().resource('test').list();
expect(response.body).toEqual([1, 2]);
});
});
Client
To allow more non-developers to participate, NocoBase provides a companion client plugin - a visual configuration interface without code. The client plugin needs to be used in conjunction with @nocobase/client and can be used directly or modified by yourself.
Plugin configuration
app.plugin('@nocobase/plugin-client', {
// Customize the dist path
dist: path.resolve(__dirname, '. /node_modules/@nocobase/client/app'),
});
To meet the needs of various scenarios, the client @nocobase/client
provides a rich set of basic components.
- Action - Action
- Window Open in the current browser window/tab
- Drawer opens the drawer (default right-hand drawer)
- Action.Modal Open dialog box
- Dropdown Dropdown menu
- Popover Bubble card
- Action.Group Grouping of buttons
- Action.Bar Action Bar
- AddNew "Add" module
- AddNew.CardItem - add a block
- AddNew.PaneItem - add block (view panel, related to currently viewed data)
- AddNew.FormItem - add fields
- BlockItem/CardItem/FormItem - decorators
- BlockItem - normal decorator (no wrapping effect)
- CardItem - card decorator
- FormItem - field decorator
- Calendar - Calendar
- Cascader - Cascade selection
- Chart - Chart
- Checkbox - Checkboxes
- Checkbox.Group - Multiple checkboxes
- Collection - Data Table Configuration
- Collection.Field - Data table fields
- ColorSelect - color selector
- DatePicker - date picker
- DesignableBar - Configuration Toolbar
- Filter - filter
- Form - Form
- Grid - Grid layout
- IconPicker - Icon selector
- Input - input box
- TextArea - Multi-line input box
- InputNumber - Number box
- Kanban - Kanban board
- ListPicker - list picker (for selecting and displaying associated data)
- Markdown editor
- Menu - Menu
- Password - password
- Radio - radio box
- Select - selector
- Table - Table
- Tabs - Tabs
- TimePicker - time picker
- Upload - Upload
You can extend the component by yourself, the above component is built based on Formily, how to customize the component you see the related component source code or Formily documentation, here is something different.
- How to extend the database fields?
- How to add third party blocks to the AddNew module?
- How to add more built-in actions to the action bar?
- How can I customize the configuration toolbar?
In addition to the components having flexible extensions, the client can also be used in any front-end framework to customize Request and Router, e.g.
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ClientSDK, Application } from '@nocobase/client';
// Initialize the client instance
const client = new ClientSDK({
request: (options) => Promise.resolve({}),
});
// Adapting the Route Component
const RouteSwitch = createRouteSwitch({
components: {
AdminLayout,
AuthLayout,
RouteSchemaRenderer,
},
});
ReactDOM.render(
,
document.getElementById('root'),
);
For more details, you can initialize the project scaffolding and experience it via create-nocobase-app
.
yarn create nocobase-app my-nocobase-project
By default, nocobase-app uses umijs as the project builder and integrates Server as the data interface. The initialized directory structure is as follows
|- src
|- pages
|- apis
|- .env
|- .umirc.ts
|- package.json
Cases
Small MIS with full front and back ends.
API service with no client, providing a pure back-end interface.
Applet + Backend admin, only one set of database, but two sets of users and permissions, one for backend users and one for applet users.
SaaS service (shared user), each application has its own supporting database and the data of each application is completely isolated. Applications don't need user and permission modules, SaaS master is shared globally now.
SaaS service (independent user), each app has its own independent user module and permissions, and the app can be bound to its own domain.