无缝客户端服务器

任何客户机/服务器项目都意味着将代码库明确分为客户机和服务器两部分(有时更多)。 通常,每个这样的部分都以独立的独立项目的形式执行,并得到其自己的开发人员团队的支持。

在本文中,我对标准的硬划分为后端和前端的代码提出了批判性的观点。 并考虑一种替代方法,其中代码在客户端和服务器之间没有清晰的界线。



标准方法的缺点


将项目标准分为两部分的主要缺点是客户端和服务器之间的业务逻辑受到侵蚀。 我们在浏览器中以表格的形式编辑数据,并在客户端代码中对其进行验证,然后将其发送到祖父的村庄(到服务器)。 该服务器已经是另一个项目。 在那里,您还需要检查接收到的数据的正确性(即,重复客户端的功能),进行一些其他操作(保存在数据库中,发送电子邮件等)。

因此,为了跟踪从浏览器中的表单到服务器上的数据库的信息的整个路径,我们将不得不深入研究两个不同的系统。 如果将角色划分为一个团队,并且后端和前端负责不同的专家,则与它们的同步相关的其他组织问题也会出现。

让我们做梦


假设我们可以用一种模型描述从客户端表单到服务器数据库的整个数据路径。 在代码中,它可能看起来像这样(代码不起作用):

class MyDataModel { //         verifyData(data) { //   .... return true; } //       client saveData(data) { if(this.verifyData(data)) this.writeDataToDb(data) else consol.log('error') } //  .     server writeDataToDb(data) { if(this.verifyData(data)) this.db.insert(data) else consol.log('error') } } 

因此,模型的整个业务逻辑摆在我们眼前。 维护这样的代码更加容易。 将客户端-服务器方法组合到一个模型中可以带来以下优点:

  1. 业务逻辑集中在一个地方,无需在客户端和服务器之间共享它。
  2. 在项目开发过程中,您可以轻松地将功能从服务器转移到客户端或从客户端转移到服务器。
  3. 无需为后端和前端重复相同的方法。
  4. 针对项目的整个业务逻辑的一组测试。
  5. 用垂直的项目替换项目中职责划分的水平线。

我将更详细地介绍最后一点。 想象一下这样的方案形式的常规客户端-服务器应用程序:



Vasya负责前端,Fedya-负责后端。 责任的划分线是水平的。 该方案具有任何垂直结构的缺点-难以缩放并且容错能力低。 如果项目正在扩展,您将不得不做出一个相当困难的选择:谁来加强Vasya或Fedya? 如果Fedya生病或辞职,Vasya将无法替代他。

这里提出的方法使您可以将责任划分范围扩大90度,并将垂直架构转换为水平架构。



这样的体系结构更容易扩展,并且具有更高的容错能力。 Vasya和Fedya可以互换。

从理论上讲,它看起来不错,让我们尝试在实践中实现所有这些功能,而又不会失去使我们一路独立存在的客户端和服务器的一切。

问题陈述


我们绝对不必在产品中集成客户端服务器。 相反,从所有角度来看,这样的决定都是极其有害的。 任务是在开发过程中,我们将为后端和前端的数据模型提供一个单一的代码库,但输出将是一个独立的客户端和服务器。 在这种情况下,我们将获得标准方法的所有优势,并获得上面列出的便利设施,用于项目的开发和支持。

解决方案


我已经尝试将客户端和服务器集成到一个文件中一段时间​​了。 直到最近,主要问题还是在标准JS中,客户端和服务器上的第三方模块的连接太不同了:node.js中的require(...),客户端上的所有AJAX魔术。 随着ES模块的出现,一切都发生了变化。 在现代浏览器中,很长一段时间以来一直支持“导入”。 在这方面,Node.js稍有落后,并且仅在启用“ --experimental-modules”标志的情况下才支持ES模块。 希望在可预见的将来,模块将在node.js中开箱即用。 此外,事情不太可能发生太大变化,因为 在浏览器中,默认情况下该功能已经运行了很长时间。 我认为现在您不仅可以在客户端使用ES模块,而且可以在服务器端使用ES模块(如果您对此主题有反对意见,请在注释中编写)。

解决方案如下所示:



该项目包含三个主要目录:

受保护 -后端;
公共 -前端;
共享 -共享的客户端-服务器模型。

一个单独的观察器进程监视共享目录中的文件,并进行任何更改,分别为客户端和服务器(在保护目录/共享目录和公共目录/共享目录中)创建更改文件的版本。

实作


考虑一个简单的实时Messenger的示例。 我们将需要新的node.js(我的版本为11.0.0)和Redis(此处未介绍如何安装)。

克隆一个例子:

 git clone https://github.com/Kolbaskin/both-example cd ./both-example npm i 

安装并运行观察者进程(图中的观察者):

 npm i both-js -g both ./index.mjs 

如果一切正常,观察者将启动Web服务器并开始监视共享和受保护目录中文件的更改。 对共享进行更改时,将为客户端和服务器创建相应版本的数据模型。 更改为保护后,观察者将自动重新启动Web服务器。

您可以通过单击链接在浏览器中查看Messenger的性能

http://localhost:3000/index.html?token=123&user=Vasya

(令牌和用户是任意的)。 要模拟多个用户,请通过指定其他令牌和用户在另一个浏览器中打开同一页面。

现在一点代码。

Web服务器


保护/ server.mjs

 import express from 'express'; import bodyParser from 'body-parser'; // -     //  -  import wsServer from './lib/wsServer.mjs'; const app = express(); //   - wsServer(app); //  mime  mjs express.static.mime.define({'application/javascript': ['js','mjs']}); app.use( bodyParser.json() ); app.use(bodyParser.urlencoded({ extended: true })); //      public app.use(express.static('public')); const server = app.listen(3000, () => { console.log('server is running at %s', server.address().port); }); 

这是一台普通的快递服务器,这里没有什么有趣的。 node.js中的ES模块需要mjs扩展名。 为了保持一致性,我们将为客户端使用此扩展名。

顾客


公共/ index.html

 <!DOCTYPE html> <html lang="en"> <head> ... <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <script src="/main.mjs" type="module"></script> </head> <body> ... <ul id="users"> <li v-for="user in users"> {{ user.name }} ({{user.id}}) </li> </ul> <div id="messages"> <div> <input type="text" v-model="msg" /> <button v-on:click="sendMessage()"></button> </div> <ul> <li v-for="message in messages">[{{ message.date }}] <strong>{{ message.text }}</strong></li> </ul> </div> </body> </html> 

例如,我在客户端上使用Vue,但这不会改变本质。 除了Vue,可以在任何地方将数据模型分为一个单独的类(敲除,角度)。

公共/ main.mjs

 //      - import ws from "/lib/Ws.mjs"; //       import Messages from "./shared/messages/model/dataModel.mjs"; //    import Users from "./shared/users/model/dataModel.mjs"; //  - (     ) window.WS = new ws({ token: new URLSearchParams(document.location.search).get("token"), user: new URLSearchParams(document.location.search).get("user") }); //       new Messages({ el: '#messages' }) //       new Users({ el: '#users' }) 

main.mjs是一个脚本,用于将数据模型与相应的视图相关联。 为了简化代码,将活动用户列表和消息提要的示例表示形式直接内置到index.html中

资料模型


共享/消息/模型/ dataModel.mjs

 //    //          , //    import Base from '@root/lib/Base.mjs'; export default class dataModel extends Base { //!#client constructor(attr) { attr.data = { msg: '', messages: [] } super(attr); //     this.on('newmessage', (data) => { this.messages.push(data) }) } //!#client async sendMessage(e) { //    await this.$sendMessage(this.msg); this.msg = ''; } //!#server async $sendMessage(text) { //   newmessage     this.fireEvent('newmessage', 'all', { date: new Date(), text }) return true; } } 

这几种方法实现了实时发送和接收消息的所有功能。 指令!#Client和!#Server告诉观察者进程打算使用哪个方法(客户机或服务器)。 如果在定义方法之前没有这些指令,则该方法在客户端和服务器上均可用。 指令前的斜杠是可选的,并且仅用于防止标准IDE在语法错误时发誓。

路径的第一行使用&根查询。 生成客户端和服务器版本时,&root将分别替换为公共目录和受保护目录的相对路径。

另一个要点:在客户端方法中,您只能调用服务器方法,该服务器方法的名称以“ $”开头:

 ... //    async sendMessage(e) { await this.$sendMessage(this.msg); <-    this.msg = ''; } ... 

出于安全原因这样做是:从外部,您只能使用专门设计的方法。

让我们看一下观察者为客户端和服务器生成的数据模型的版本。

客户端 (公共/共享/消息/模型/dataModel.mjs)

 import Base from '/lib/Base.mjs'; export default class dataModel extends Base { __getFilePath__() {return "messages/model/dataModel.mjs"} // constructor(attr) { attr.data = { msg: '', messages: [] } super(attr); //     this.on('newmessage', (data) => { this.messages.push(data) }) } // async sendMessage(e) { //    await this.$sendMessage(this.msg); this.msg = ''; } // ... async $sendMessage() {return await this.__runSharedFunction("$sendMessage",arguments)} } 

在客户端,该模型是Vue类的后代(通过Base.mjs)。 因此,您可以像使用常规Vue数据模型一样使用它。 观察者在模型的客户端版本中添加了__getFilePath__方法,该方法返回了类文件的路径,并用一种​​构造替换了$ sendMessage服务器方法代码,该构造本质上将通过rpc机制在服务器上调用我们需要的方法(__runSharedFunction在父类中定义)。

服务器 (受保护/共享/消息/模型/dataModel.mjs)

 import Base from '../../lib/Base.mjs'; export default class dataModel extends Base { __getFilePath__() {return "messages/model/dataModel.mjs"} ...       ... // async $sendMessage(text) { //   newmessage     this.fireEvent('newmessage', 'all', { date: new Date(), text }) return true; } } 

在服务器版本中,还添加了__getFilePath__方法,并删除了带有伪指令的客户端方法!

在两个模型的生成版本中,所有删除的行均被空行替换。 这样做是为了使调试器上的错误消息可以轻松地在模型的源代码中找到有问题的行。

客户端-服务器交互


当我们需要在客户端上调用某些服务器方法时,只需执行此操作。
如果调用在同一模型内,则一切都很简单:

 ... !#client async sendMessage(e) { await this.$sendMessage(this.msg); this.msg = ''; } !#server async $sendMessage(msg) { // -    } ... 

您可以“拉”另一个模型:

 import dataModel from "/shared/messages/model/dataModel.mjs"; var msg = new dataModel(); msg.$sendMessage('blah-blah-blah'); 

在相反的方向,即 在服务器上调用某些客户端方法无效。 从技术上讲,这是可行的,但从实际的角度来看,这是没有意义的,因为 服务器是一台,但是有很多客户端。 如果需要在客户端的服务器上启动某些操作,则可以使用事件机制:

 //    ... //!#client constructor(attr) { .... //       "newmessage" this.on('newmessage', (data) => { this.messages.push(data) }) } //!#server async $sendMessage(text) { //     newmessage     this.fireEvent('newmessage', 'all', { date: new Date(), text }) return true; } ... 

fireEvent方法采用3个参数:事件的名称,事件的名称和数据。 您可以通过以下几种方式设置接收者:关键字“全部”-事件将被发送给所有用户或在数组中列出事件所针对的客户端的会话令牌。

该事件未绑定到数据模型类的特定实例,并且处理程序将在调用fireEvent的类的所有实例中触发。

水平后端缩放


乍一看,所提出的实现中客户端-服务器模型的整体性应该对服务器部分的水平扩展的可能性施加重大限制。 但是事实并非如此:从技术上讲,服务器不依赖于客户端。 您可以在任何地方复制“公共”目录,并通过任何其他Web服务器(nginx,apache等)提供其内容。

通过启动新的后端实例,可以轻松扩展服务器端。 Redis和Kue队列系统用于与单个实例进行交互。

API和不同的客户端到一个后端


在实际项目中,不同的服务器客户端可以使用一个服务器API-网站,移动应用程序,第三方服务。 在建议的解决方案中,所有这些都可用,而无需任何其他操作。 调用服务器方法的背后是很好的旧rpc。 Web服务器本身是经典的快速应用程序。 通过调用相同数据模型的必要方法,为路由添加一个包装器就足够了。

投稿后


本文中提出的方法不会假装对客户端-服务器应用程序进行任何革命性的更改。 它只会给开发过程带来一点安慰,使您可以集中精力于一个地方组装的业务逻辑。

该项目是实验性的,请在评论中写下您认为是否值得继续进行该实验。

Source: https://habr.com/ru/post/zh-CN435494/


All Articles