nodejs_NodeJS的EventSourcing

袁俊弼
2023-12-01

nodejs

Recently I’ve been building a small application using Event-Sourcing in NodeJS.

最近,我一直在使用NodeJS中的事件源构建一个小型应用程序。

Usually I’ve done it in Ruby, doing in NodeJS was interesting and wanted to share it with you.

通常,我是在Ruby中完成的,在NodeJS中进行的很有趣,并希望与您分享。

I took the example of WhatsApp: we’re going to build a sub-part of WhatsApp, specifically the Group feature.We’re not gonna build the chat feature … :)

我以WhatsApp为例:我们将构建WhatsApp的子部分,特别是Group功能。我们不会构建聊天功能…:)

Let’s start from the interactions:

让我们从交互开始:

  • create group

    创建组
  • add participant

    添加参与者
  • remove participant

    删除参与者
  • change subject

    换主题

There is a one-to-one mapping to the domain events for each action.

每个操作都与域事件存在一对一的映射。

Simply:

只是:

  • GroupWasCreated {groupSubject, userIds, currentUserId}

    GroupWasCreated {groupSubject,userIds,currentUserId}
  • ParticipantWasAdded {groupId, userId, admin}

    参加者Wa Transactions {groupId,userId,admin}
  • ParticipantWasRemoved{groupId, userId, admin}

    ParticipantWasRemoved {groupId,userId,admin}
  • SubjectWasChanged {groupSubject}

    SubjectWasChanged {groupSubject}

That’s the beauty of Event-Sourcing, it’s a no brainer to design interactions exactly how we would talk about them, and the becomes actual code. No need to think about database models or relationships: we describe the system exactly how a human would do.

那就是事件源的美,完全按照我们如何谈论交互的方式来设计交互,然后变成实际的代码,这毫不费吹灰之力。 无需考虑数据库模型或关系:我们准确地描述了系统人类的行为。

We’re basically implementing an Aggregate-based micro-service. We could see WhatsApp::Group as the AggregateRoot, and identify two entities: WhatsApp::Group and WhatsApp::GroupParticipant. The latter referencing a remote aggregate with a userId foreign key.

我们基本上是在实现基于聚合的微服务。 我们可以将WhatsApp :: Group视为AggregateRoot,并标识两个实体:WhatsApp :: Group和WhatsApp :: GroupParticipant。 后者引用带有userId外键的远程聚合。

Let’s define a service API (or basically an AggregateRoot api) for the features (this will be used by our HTTP API layer, ie. ExpressJS; but could be as well hooked into a message bus like Kafka, receiving command messages).

让我们为这些功能定义一个服务API(或基本上是一个AggregateRoot api)(将由我们的HTTP API层即ExpressJS使用;但也可以像挂在Kafka上的消息总线一样,接收命令消息)。

We’re going to assume all methods are async and returning Promises. NB: I'm not a Node.JS expert, I'm rather a Ruby expert. So if you have any code recommendation, feel free to comment.

我们将假设所有方法都是async并返回Promises。 注意:我不是Node.JS专家,而是Ruby专家。 因此,如果您有任何代码建议,请随时发表评论。

We have a choice into how to implement the createGroup method:

我们可以选择如何实现createGroup方法:

  • either using a single event GroupWasCreated (which includes the original participants and subject) that would be enough to describe the participant list, subject, and group admin.

    使用单个事件GroupWasCreated(包括原始参与者和主题)就足以描述参与者列表,主题和组管理员。
  • or emit events ParticipantWasAdded and SubjectWasChanged along when emitting the GroupWasCreated.

    或在发出GroupWasCreated时发出事件ParticipantWawego和SubjectWasChanged。

The first solution reduce the number of events and has implicit behaviour; while the latter make it clear what happens at creation time and also allow more reusable logic for both the command side (createGroup calls addParticipant) and the query side (we don’t have to process GroupWasCreated as a special event and extract participant from it, because we know ParticipantWasAdded events are being emitted along with it).

第一种解决方案减少了事件的数量并具有隐含的行为; 后者可以清楚地说明在创建时会发生什么,并且还允许命令端(createGroup调用addParticipant)和查询端(我们不必将GroupWasCreated作为特殊事件进行处理并从中提取参与者)都具有更多可重用的逻辑,因为我们知道与之一起发出ParticipantWa⚓事件)。

For reusability (DRY) and clarity (no implicit behaviour), I’ll choose the second solution and emit each event.

为了实现可重用性(DRY)和清晰度(无隐式行为),我将选择第二个解决方案并发出每个事件。

I’m using a library that helps me store and fetch events : eventstore on npm.

我正在使用可帮助我存储和获取事件的库:npm上的eventstore

npm install --save eventstore

npm install --save eventstore

npm install --save uid

npm install --save uid

There is an in-memory adapter (basically a fake database) but the lib also provide a MongoDB and Redis adapter. I’ve personally used Redis in a previous project, but MongoDB should be the default choice imo.

有一个内存中适配器(基本上是一个伪数据库),但该库还提供了MongoDB和Redis适配器。 我在上一个项目中亲自使用过Redis,但是MongoDB应该是imo的默认选择。

I’ll initialise the lib in the constructor of my service and add a small Promise wrapper around loading events to have a nice consistent Promise api.

我将在服务的构造函数中初始化库,并在加载事件周围添加一个小的Promise包装器,以具有一个很好的一致的Promise api。

But so far, I haven’t run the code yet.

但是到目前为止,我还没有运行代码。

So, because I’m lazy … and professional … (the lazy part is actually more accurate, but that’s another debate) I’m gonna write an automated test :)

所以,因为我很懒惰……而且很专业……(懒惰的部分实际上更准确,但这是另一个争论),我要编写一个自动化测试:)

Yes coders, if you don’t write automated tests … you’re only half professional! (according to Uncle Bob; and after 4 years of practicing BDD/TDD, I have to agree).

是的,编码人员, 如果您不编写自动化测试……则只有一半的专业知识! (根据鲍伯叔叔的说法;经过4年的BDD / TDD练习,我必须同意)。

npm install --save-dev assert

npm install --save-dev assert

npm install --save-dev mocha

npm install --save-dev mocha

As we currently have no way to get the “end-state” of our entity, we can only verify that the events are being emitted. Let me run that and check if this works … Ok just after a few fixes and that actually runs.

由于我们目前无法获取实体的“最终状态”,因此我们只能验证事件是否正在发出。 让我运行该程序,然后检查它是否有效……好了,经过一些修复后,该程序才真正运行。

Code at this step is on github on branch “step-1”. You can pull it and try to run npm install and npm test.

此步骤的代码位于github上分支“ step-1”上 。 您可以将其拉出并尝试运行npm installnpm test

Notice that the aggregate behaviour is defined by (at least for the creation):

注意,聚合行为是由(至少对于创建)定义的:

  • When Command

    当命令
  • Then Events

    然后事件

And that’s reflected in the test. When we’ll be implementing “update” command (like “addParticipant”), we would have:

这反映在测试中。 当我们要实现“更新”命令(例如“ addParticipant”)时,我们将有:

  • Given Events

    给定事件
  • When Command

    当命令
  • Then Events

    然后事件

We’ve been building so far the “Command Side” of our application with currently a single command, but no feature to read the state of the application (except the stream of events which is not super user-friendly).And that’s why EventSourcing is coupled with CQRS: Command / Query segregation.

到目前为止,我们一直在使用单个命令来构建应用程序的“命令侧”,但是没有读取应用程序状态的功能(事件流不是超级用户友好的)。这就是为什么EventSourcing与CQRS结合使用:命令/查询隔离。

The code of the application at this stage can be found on github branch step-1.

这个阶段的应用程序代码可以在github分支step-1上找到

到目前为止呢? (Whatsup so far ?)

Let’s realise that with this, we have a persistence model that can accept unstructured data and persist a Has-Many relationship for the Participants.

让我们意识到,有了这个持久性模型,我们可以接受非结构化数据并为参与者保持Has-Many关系。

This is powerful on it’s own: you don’t have to do any database migration or deep thinking if you want to add a new commands or persist another “belongs-to” or “has-many” association; as long as you have a clear AggregateRoot defined in your ubiquitous domain language and Event-Sourcing for it’s persistence, it’s is very easy to add behaviour in a clear and consistent manner. We’ll see that in the next step.

它本身具有强大的功能:如果要添加新命令或保留另一个“属于”或“具有许多”关联,则无需进行任何数据库迁移或深入思考; 只要您在无处不在的域语言中定义了清晰的AggregateRoot并具有事件持久性(因为它具有持久性),就很容易以清晰一致的方式添加行为。 我们将在下一步中看到它。

Let’s say we want to add notification settings particular to this group, it’s simply a new command and a new event. Really straightforward in terms of the Command Model: there is no database changes and the only changes required is limited to the domain needs: a command, an event; that’s it.

假设我们要添加特定于该组的通知设置,这只是一个新命令和一个新事件。 就命令模型而言,这非常简单:没有数据库更改,仅需要进行的更改仅限于域需求:命令,事件; 而已。

让我们与CRUD进行比较: (Let’s compare to CRUD:)

We would have need a Group model and a GroupParticipant model linked with a has_many/belongs_to relationship. Both would have method like "create" and "update" and "destroy" from the underlying ORM. The CreateGroup logic you would either add participant using GroupParticipant.create inside the controller (bad idea imo). A bit better would be in the model, overriding the Group.create method to the custom domain logic which would use GroupParticipant.create. But better: you could build a service module (outside of the model) around each AggregateRoot (exactly as we did here) that would define all commands and queries available around that aggregate. This will end up with a code structure quite similar to what we have. But not very common in MVC fameworks: Rails and others don't encourage this type of code structure.

我们将需要一个具有has_many/belongs_to关系的Group模型和GroupParticipant模型。 两者都具有来自底层ORM的“创建”,“更新”和“销毁”之类的方法。 您可能会在控制器内部使用GroupParticipant.create添加参与者的CreateGroup逻辑(坏主意imo)。 在模型中会更好一些,将Group.create方法重写为使用GroupParticipant.create的自定义域逻辑。 但更好的是:您可以围绕每个AggregateRoot(完全像我们在此处所做的那样)构建一个服务模块(在模型外部),该模块将定义围绕该聚合的所有可用命令和查询。 最终将得到与我们非常相似的代码结构。 但是在MVC fameworks中不是很常见:Rails和其他人不鼓励这种类型的代码结构。

Developing outside of ORM defaults can make your code cleaner using Clean Architecture concepts of aggregate, repository and value-object.

在ORM默认值之外进行开发可以使用“聚合”,“存储库”和“价值对象”的“干净架构”概念使代码更整洁。

But even with a Clean-Code architecture, with classic MVC you would need two DB tables and any new feature would likely need a database migration.

但是,即使使用Clean-Code架构,使用经典MVC,您也将需要两个DB表,而任何新功能都可能需要数据库迁移。

状态? (State ?)

Now, let’s look at how we can get some state. Currently we can query events, usually by aggregate_id, but could be loaded by aggregate_type or maybe by event_name. But that's not going to be efficient (for example, querying all Groups with more than 2 participants would require to process all ParticipantWasAdded events) nor human-friendly (we don't have an easy way to get the end-state of the group, only its events).

现在,让我们看一下如何获得某种状态。 当前,我们通常可以通过aggregate_id查询事件,但是可以通过aggregate_typeevent_name加载event_name 。 但这并不会有效(例如,查询参与者超过2人的所有网上论坛都需要处理所有ParticipantWasAdded事件),也不是人类友好的(我们没有一种简单的方法来获取网上论坛的最终状态,仅其事件)。

The common solution to this problem is to implement a Projection which is processing all events and updating a special database made for querying.

解决此问题的常用方法是实现一个Projection,该Projection处理所有事件并更新用于查询的特殊数据库。

投影中的建筑状态 (Building state in a Projection)

We can see this as a standalone micro-service streaming events from a message bus, updating a database with incremental changes, combining the Projection state with the new event. It wouldn’t have access to history of events, but rather the real time stream.

我们可以将其视为来自消息总线的独立微服务流事件,使用增量更改来更新数据库,并将Projection状态与新事件相结合。 它无法访问事件历史,而只能访问实时流。

A Projection is:

投影为:

  • Given State (Projection state)

    给定状态(投影状态)
  • When Event (new Aggregate event)

    当事件(新的汇总事件)
  • Then State (new Projection state)

    然后状态(新投影状态)

减少值对象中的状态 (Reducing state in a value object)

In Event-Sourcing literature, usually we talk about the Read Model being a Projection built out of an event stream. But there’s another option: to load an Aggregate’s history and reduce it to it’s end-state.

在事件源文献中,通常我们将读取模型说成是基于事件流构建的投影。 但是还有另一种选择:加载聚合的历史记录并将其缩减为最终状态。

Motivation: A few years ago, at my previous work, after we were using Event-Sourcing for around 1.5 year, I was struggling with the complexity of Projection and replay: it wasn’t easy to be 100% confident with the end-state of an entity.

动机:几年前,在我之前的工作中,在使用事件搜索约1.5年之后,我在投影和重播的复杂性中苦苦挣扎:要想对最终状态100%的自信并不容易实体的

For this, I came up with the idea of building Reducers around Aggregate’s history. Back then I called them StateModel: as they represent a way of building an end-state of an Aggregate; there could be multiple StateModel (different interpretation) for a single Aggregate type. Now I believe Reducer is the right term as it is well known in ReactJS … and the idea is very similar.

为此,我想到了根据Aggregate的历史构建减速器的想法。 那时我称它们为StateModel :因为它们代表一种建立聚合终点状态的方式; 单个Aggregate类型可能有多个StateModel(不同的解释)。 现在我相信, Reducer是正确的术语,因为它在ReactJS中是众所周知的……这个想法非常相似。

A Reducer is:

减速器是:

  • Given Events (an Aggregate’s history)

    给定事件(汇总历史)
  • Then State

    然后陈述

and it can be seen as a ValueObject (functional).

它可以看作是ValueObject(功能性)。

This has the huge advantage of not being dependent on a Projection Database (which would usually imply eventual consistency). With this idea, we can return the end-state of an entity (that would be returned by a REST endpoint like /group/:id) without the need of an extra Projection. Of course, this won't allow us to easily query Groups.

这具有不依赖于Projection Database的巨大优势(通常意味着最终的一致性)。 有了这个想法,我们可以返回实体的最终状态(由诸如/group/:id类的REST端点返回),而无需额外的Projection。 当然,这将不允许我们轻松查询组。

So, let’s write a minimalistic Read Model to easily get the state of our aggregate.

因此,让我们编写一个简约的读取模型以轻松获取聚合的状态。

Let’s do it, but … let’s start with the tests :D

让我们开始吧,但是……让我们从测试开始:D

We should load the events for our test … but because I’m lazy, and because I prefer my tests to use the service api, I’m not gonna extract the events from the previous test to create my “Given” case, but instead, I’m gonna use the service api and simply use the CreateGroup command to populate the events in the database:

我们应该为测试加载事件……但是因为我很懒,并且因为我更喜欢测试使用服务API,所以我不会从以前的测试中提取事件来创建“给定”案例,而是,我将使用服务api并仅使用CreateGroup命令来填充数据库中的事件:

We can see that, through the test, I defined the api behaviour and the expected payload. I also went ahead and added createdAt and updatedAt attributes that will be inferred from the events.

我们可以看到,通过测试,我定义了api行为和预期的有效负载。 我也说干就干,加入createdAtupdatedAt属性将会从事件中推断出来。

Let’s implement the Reducer:

让我们实现Reducer:

And the module function to complete our aggregate api:

并使用模块功能来完成我们的汇总API:

Which give us a green test and the following end-state payload:

这为我们提供了绿色测试和以下最终状态有效负载:

We now have a Command that generates Events, which those Events can be reduced into a human friendly end-state.Let’s note that the reducer is simplified by the fact we’re emitting multiple events when creating the group.

现在我们有了一个生成事件的命令,可以将这些事件简化为人类友好的最终状态。请注意,由于在创建组时会发出多个事件,简化了化简器。

From this, it is easy to create a Query Database: simply save this end-state into a NoSQL database in an after-save hook of the AggregateRoot!

由此,创建查询数据库很容易:只需在AggregateRoot的后保存钩中将此最终状态保存到NoSQL数据库中即可!

But … you might not need that for simple applications…

但是……对于简单的应用程序,您可能不需要它……

Code at this state is on github branch step-2

此状态下的代码在github分支step-2上

让我们添加缺少的动作 (Let’s add the missing actions)

AddParticipant is straightforward if we don’t verify that the user is not already present (we should, to avoid redundant events). For RemoveParticipant we have to check if the userId is in the participant list. If not, we should raise an error as the command is invalid.

如果我们不验证用户是否不存在,AddParticipant会很简单(我们应该避免重复事件)。 对于RemoveParticipant,我们必须检查userId是否在参与者列表中。 如果不是,我们应该提出一个错误,因为该命令无效。

For that, I’m gonna use the aggregateState and add a helper function hasParticipant(userId) to the state object.

为此,我将使用aggregateState并将辅助函数hasParticipant(userId)到状态对象。

First writing the tests:

首先编写测试:

And the domain code:

和域代码:

I added a validation for not executing command on aggregate that doesn’t not exist (untested 臘).

我添加了一个验证,该验证用于对不存在的聚合(未测试的臘)不执行命令。

I extract specific attributes of the command payload with const {groupId, userId, admin} = payload; to avoid unknown attributes in my events. More on data validation later.

我用const {groupId,userId,admin} =有效负载来提取命令有效负载的特定属性 避免事件中出现未知属性。 有关数据验证的更多信息。

And the domain state object:

和域状态对象:

Ok my tests passes, but there is one thing missing, if I load the state after removing a participant, the participant is still in the list, and that is because I don’t reduce the ParticipantWasRemoved event.

好的,我的测试通过了,但是缺少一件事,如果在删除参与者之后加载状态,则该参与者仍在列表中,这是因为我没有减少ParticipantWasRemoved事件。

Let’s adapt the test and include the state assertion along inside the current test.

让我们修改测试,并在当前测试中包括状态声明。

Let’s add the event to our reducer to return a valid state:

让我们将事件添加到我们的reducer中以返回有效状态:

And voilà!That’s it for the code today ..

真是的,这就是今天的代码..

如何更新groupSubject? (What about updating groupSubject?)

Easy! No ?

简单! 不是吗

Basically, what is required is to implement the changeSubject method of our service so that it emits SubjectWasChanged on an aggregateId (ie. groupId) that exists, no more. It doesn’t require reducing the aggregate’s state to validate the command, nor adding the event to the Reducer: it’s already there.

基本上,所需的是实现我们服务的changeSubject方法,以便它在不存在的aggregateId(即groupId)上发出SubjectWasChanged。 它不需要减少聚合的状态来验证命令,也不需要将事件添加到Reducer:它已经存在。

If you’re up for a little challenge, comment your code snippet of changeSubject(payload) in the comments! ;-)

如果您面临一些挑战,请在注释中注释您的changeSubject(payload)代码段! ;-)

你怎么看 ? (What do you think ?)

What do you think about this process of writing new features using: command + events + reducer ?

您如何看待使用以下命令编写新功能的过程:命令+事件+减速器?

And the tests ?

和测试?

Quite straightforward (consistant) and powerful to my opinion: once the setup is done, it is easy to add custom features that would usually require database design and migration, while here we just implement what the business is talking about.

我认为这非常简单(一致)且功能强大:设置完成后,很容易添加通常需要数据库设计和迁移的自定义功能,而在这里,我们仅实现企业正在谈论的内容。

那销毁呢? (What about destroy ?)

What if we want to destroy the group ? Well, we should just add a new command, a new event GroupWasDestroyed in the reducer and decide there if we keep the state and just add a field deleted_at, or nulling the state. Another option would be to destroy the aggregate’s stream… but that means losing this information forever. Unless under GDPR implication, I wouldn’t consider doing that.

如果我们想消灭这个团体怎么办? 好吧,我们应该只添加一个新命令,在化GroupWasDestroyed中添加一个新事件GroupWasDestroyed ,然后决定是否保留状态并仅添加一个字段Deleted_at或使状态为 。 另一个选择是销毁聚合流……但这意味着永远丢失此信息。 除非受到GDPR的影响,否则我不会考虑这样做。

In the end, it’s up to the business to decide domain details, and that’s the beauty of EventSourcing: you focus on exactly what you care about, implementing them usingGiven state(events), When action, Then state(events + newEvents)

最后,由业务来决定域详细信息,这就是EventSourcing的优点:您专注于自己关心的事情,使用给定state(events),When action,然后state(events + newEvents)实现它们

它可以投入生产了吗? (Is it production-ready ?)

Apart that we don’t have much capability to query Groups (the only current way is to load all aggregate), there is a significant part (to my opinion) missing to this application …

除了我们没有查询组的能力(当前的唯一方法是加载所有聚合)之外,(在我看来)此应用程序缺少很大一部分……

Can you guess ? Well … the database… is the gold mine of the business application. And we don’t have much in place to enforce the sanity of our Events.

猜一下 ? 嗯……数据库……是业务应用程序的金矿。 而且我们没有足够的地方来强制执行活动的合理性。

Currently we guard against:

目前,我们谨防:

  • avoid executing commands on non existing Aggregate.

    避免在不存在的聚合上执行命令。
  • avoid creating a group on an already existing aggregate id.

    避免在已经存在的聚合ID上创建组。
  • avoid emitting ParticipantWasRemoved when the user does not exist.

    当用户不存在时,避免发出ParticipantWasRemoved。
  • and we filter command payload to only the known attribute.

    并且我们将命令有效负载过滤为仅已知属性。

What about:

关于什么:

  • userId: is this an email ? a UUID? a UID? This can be anything as of now. That’s the case for all fields right now: we don’t constraint their type. In a serious application I’d definitely closely validate the type of each field.

    userId:这是电子邮件吗? 一个UUID? 一个UID? 到目前为止,这可以是任何东西。 目前所有字段都是这种情况:我们不限制其类型。 在一个严肃的应用程序中,我肯定会仔细验证每个字段的类型。
  • event definition ? We have implicit event definitions, but not explicit. Currently it’s just the event name and payload being commonly known, but there isn’t a central place to read those definitions. A mistake (in the event name for example) is easy to make and not easy to find.

    事件定义? 我们有隐式事件定义,但没有显式事件定义。 当前,只是事件名称和有效负载是众所周知的,但是没有一个中心位置可以读取这些定义。 错误(例如事件名称)很容易犯,而且不容易发现。

So basically, what we’re heavily missing is Event schema definition.

因此,基本上,我们缺少的是事件模式定义。

This is not a big issue if the api is small and you’re the only developer using it, because, ideally, you wouldn’t make mistakes. But in the real world, you’ll make mistakes, and you could erroneously push invalid data into your payload. So you should guard your database against yourself … cause you’re human … you make mistakes. And you will! :)

如果api很小并且您是唯一使用它的开发人员,那么这不是什么大问题,因为理想情况下,您不会犯错误。 但是在现实世界中,您会犯错误,并且可能错误地将无效数据推入有效负载中。 因此,您应该保护数据库免受自己的侵害……因为您是人类……您会犯错误。 而且你会! :)

Code at this state is on github branch step-3

此状态下的代码在github分支step-3上

结论 (Conclusion)

And voila, with this example you can see my coding style when it comes to define an Aggregate-based microservice:

瞧,在这个示例中,您可以看到我的编码风格来定义基于聚合的微服务:

Basically we have the following pieces:

基本上,我们有以下几部分:

  • service api

    服务API
  • service api tests

    服务api测试
  • aggregate (command-side)

    汇总(命令端)
  • reducer (read-side for single aggregate)

    减速器(单个聚合的读取侧)

From that, it’s easy to create a Query Database by simply saving the reducer’s computed state into a NoSQL DB.

由此,只需将化简器的计算状态保存到NoSQL DB中就可以轻松创建查询数据库。

If eventual consistency is not an issue, you can instead push the event (+ end-state, depending on your preference for delta change computation or dumb push-update) to a bus and let another process implement the Projection.

如果最终一致性不是问题,则可以将事件(+结束状态,具体取决于您对增量更改计算或哑推送更新的偏好)推送到总线,然后让另一个进程实现Projection。

In my opinion, the ideas behind EventSourcing is the (current) future of how we’ll build any serious application. CRUD would be left for simple application… For which you nearly don’t need a backend developer (which I am)… rather a front-end developer using a RESTfull database (like RestDB.io) as the backend.

我认为,EventSourcing背后的思想是(当前)将来如何构建任何严肃的应用程序。 CRUD将留给简单的应用程序……对于它,您几乎不需要后端开发人员(我是)……而是使用RESTfull数据库(如RestDB.io )作为后端的前端开发人员。

Let me know what you think about that last statement :)

让我知道您对最后一条陈述的看法:)

Note: I’ve used https://markdowntomedium.com/create to create this blog post from Markdown to Medium.

注意:我已经使用 https://markdowntomedium.com/create 创建了从Markdown到Medium的博客文章。

翻译自: https://levelup.gitconnected.com/eventsourcing-with-nodejs-5d0f8e255676

nodejs

 类似资料: