在云端使用自动更新的SSL托管Node.js https服务器,以及如何设置开发周期(+ git,react)

前言


首先,有一天我想创建一个应用程序。 产生这种愿望的原因是我喜欢阅读,但是在广阔的俄罗斯互联网中根本没有任何普通的图书汇总商。 实际上,由于不费吹灰之力来寻找要阅读的东西并试图回忆起我最近读过的书的名称以及我停在哪一章,因此诞生了创建所有这样的Web应用程序的愿望,该应用程序将使这一切变得可能且方便。 值得注意的是,没有开发,编程等方面的经验。 我没有,我的工作根本与此无关。 然而,欲望克服了懒惰,成长为具体的行为,是一种爱好。

我不会告诉我如何学习javascript,node.js,react,html,css等。我们将继续介绍目前所学的内容,我想与您分享的内容,当然,还要听听对专家的建设性批评。

像许多人一样,我在自己的本地主机上的PC上进行了培训:3000,创建了前端/后端,进行了排版,使用了api等,但是我一直担心如何将所有这些信息稍后转移到托管? 能行吗 因此是否需要重写代码? 最重要的是,是否可以配置所有内容,以便我可以从任何PC上处理该应用程序,并轻松将所有内容转移到生产环境中? 我将讨论这个。

托管选择


对于我的业余爱好,我准备每月花费10美元,因此我选择了将来打算使用的主机。 正如我所说,在此之前,我有0点经验,包括网站托管。 我尝试并拒绝了以下内容:

Jelastic :美观和用户友好的界面,一切似乎都是直观,可扩展且易于理解的。 但是,我在设置时遇到了困难(nginx由于某种原因不想从vps上工作,只能使用其单独的模块),并且通过标准方式将SSL(和自动更新)连接到俄语域(他们答应修复该错误,但我不想等待)

云托管REG.RU :我在那里也有一个域,因此该解决方案似乎合乎逻辑,但是他们没有分别配置PostgreSQL,并且由于我不想与数据库管理部门联系,所以我开始进行进一步的研究。

AWS和Google云 :我尝试过,一切似乎都很好,但是我记得我们的“奇妙”法律以及将用户数据放在俄罗斯联邦服务器上的要求。 不幸的是,这些家伙在俄罗斯联邦没有服务器。 不是律师,而是出于罪恶,决定在俄罗斯联邦寻找带有服务器的云。 如果您的应用不太可能出现法律问题,那么一个不错的选择。

尽管俄罗斯联邦的服务器上有云,但我仍然希望有一些东西可以使我免于不得不投入PostgreSQL管理。 不久前, Yandex出现了这种冲动。我尝试了一下可用的 ,似乎一切都很简单方便,所以我暂时停下来了。 值得注意的是PostgreSQL托管立即提供1core和4GB的RAM,每月花费约2k卢布,因此,在开发和低负载的时候,我计划在VPS上运行PostgreSQL〜300r,并以增加的负载转移数据库, Yandex从事管理和更新。

设置Yandex.Cloud


虚拟私有云


1)为您的站点创建一个目录:

图片

2)创建一个虚拟私有云:

他在当前阶段给我的主要内容是从外部访问创建的资源的IP。 我初步了解了子网,区域,隔离和容错功能,如有必要,我会赶上来。

3)创建一个子网并为其分配一个内部IP(据我所知,它就像一个本地网络)

图片

4)转到IP选项卡并为自己保留一个静态IP。

在它上面,我们将在家和其他地方连接。 您可能可以使用动态,但我不知道它在什么情况下会更改。

图片

计算云


在这里,我们将进行计算:)也就是说,我们将使用Linux创建虚拟机(我选择ubuntu 18.04),安装node.js应用程序和postgreSQL。

图片

我们单击以创建一个VM,将所有设置最小化,因为在开发过程中将没有负载(当我们发布应用程序时,再扭曲一点,好了,我们将通过图形进行监视)。

SSH


我在此阶段遇到的问题点是SSH:

图片

那是什么,为什么我不知道,所以我去学习。 事实证明,这只是一种访问方法,不是通过密码,而是通过生成的SSH密钥。 要实际生成它,请按照我们的建议下载并安装Putty

运行C:\ Program Files \ PuTTY \ puttygen.exe

图片

我们按下“生成”按钮并移动鼠标以使生成的键具有随机性(据我所知)。 接下来,将以ssh-rsa开头的行复制到文本文件中的某处,然后单击“保存私钥”,“保存公钥”。 复制到文本文件的密钥将插入到Yandex Yandex页面的密钥的SSH字段中。 我们将root指定为登录名,否则您将无法使用将要从home / work连接到云的应用程序的图形文件系统时进行访问(也许有办法,但我不明白)。
正如Andreymal所指出的那样,最好不要使用root,这样中国的bot才不可以获取您的云密码,但是由于Yandex.cloud仅具有SSH访问权限,因此您可以这样生活。

主机上的应用程序应专门由非root用户启动,以免攻击者通过应用程序中的漏洞执行恶意代码。


我们从PC连接到云,然后选择一个免费的SSH客户端


标准的Putty仅允许您在命令行上工作,并且由于我对Windows用户不熟悉,因此我开始寻找带有伪资源管理器的客户端。 最初,我尝试使用Mobaxterm,但是在一段时间不活动之后,它关闭了,资源管理器完全冻结,所以现在我正在使用bitvise ssh ,到目前为止,我还没有看到Mobaxterm这样的问题。

配置bitvise ssh


图片

在“服务器”>“主机”字段中,指示我们的外部IP云。 端口22。单击“客户端密钥管理器”>“导入”,然后在其中指定先前生成的私钥。 您可能仍然需要一个关键词,选择一些您不会忘记的东西。 关闭此窗口并在身份验证字段中指定用户名:root,方法publick密钥,客户端密钥-选择先前导入的密钥。 单击登录,如果我们一切正确,则连接到云:

图片

安装Node.js


在这里,我建议您使用digitalocean.com上的说明,该说明非常详细,许多使用俄语。 通常我会在Google上搜索“ digitalocean ubuntu 18.04 node.js”或您要在此处安装或配置的任何文件。

可以在这里阅读如何安装Node.js。

简而言之,我们转到nodesource (可以在此处安装最新版本的node.js),在此处进行以下操作:

图片

依次复制并运行命令:

curl -sL https://deb.nodesource.com/setup_11.x | sudo -E bash - sudo apt-get install -y nodejs 

我们检查团队如何建立它

 nodejs -v 

我们将看到node.js的版本

 npm -v 

我们将显示node.js的软件包管理器的版本。

接下来,转到/ opt / mysuperapp文件夹(my_super_app_name-您必须创建此文件夹)。 长时间搜索“将应用程序的node.js文件放入ubuntu适当的位置”之后,选择opt目录作为应用程序的位置。

最后,创建server.js文件,它将是应用程序的入口,然后将简单的服务器代码粘贴到node.js上:

 const http = require('http'); const hostname = 'localhost'; const port = 80; const server = http.createServer((req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('Hello World!\n'); }); server.listen(port, hostname, () => { console.log(`Server running at http://${hostname}:${port}/`); }); 

端口80用于http请求,端口443用于https。 虽然我们在http上有一个服务器。

我们保存所有内容并运行命令:

 node server.js 

控制台应显示“服务器在本地主机上运行的服务器:80 /”行

现在,您可以打开浏览器,输入一个外部IP(您的ubuntu VM的Yandex云中的一个),我们将看到“ Hello World!”。

我们使用git方便地完成所有工作或开发周期


一切似乎都可以正常工作,但是我们不会一直连接到云。 而且,突然之间,我们将来不会独自工作。

Github


Github是我们应用程序代码所在的地方。 简而言之,一个人的工作原则如下:

  • 我们正在家用PC上开发应用程序。
  • 一键保存并在Github上卸载代码。
  • 在托管或另一台PC上,从github下载我们的应用程序,重新启动服务器(如果正在托管),那么我们的Web应用程序的新版本可以在万维网上找到。

一切都是快速,简单和方便的。

实际在Github上注册并为我们的应用程序创建一个私有存储库(该存储库仅对我们可用):

图片

复制github.com/ReTWi/mysuperapp.git行以下载该应用程序。

图片

  1. 我们返回bitvise命令行,按ctrl + c停止应用程序(如果仍然有效)。
  2. 转到/ opt目录,并使用我们创建的应用程序删除该文件夹

我们将使用Git将应用程序上传到github,然后从那里上传到托管或其他PC。 Git是一个单独的讨论主题,所以现在让我们开始讨论。
使用以下命令在主机上安装git

 sudo apt update sudo apt install git 

检查一切是否成立:

 git --version 

git版本应该出现。

我们填写git数据(我不明白为什么,但是显然可能会有一些无聊的警告)。

 git config --global user.name "Your Name" git config --global user.email "youremail@domain.com" 

最后,我们使用以下命令将应用程序上载到主机:
(应该有到您的应用程序的链接)

 git clone https://github.com/ReTWi/mysuperapp.git 

一个新的mysuperapp将出现在/ opt目录中,该文件是从github下载的应用程序文件所在的位置。

现在是时候对PC重复相同的操作并关闭PC链了(不同)-> Github->托管

在PC上安装node.js。

Visual Studio代码


首先,选择我们将在其中工作的源代码编辑器。 我选择了Visual Studio代码,因此它简单,方便,具有许多插件,并且如果您使用多个设备,则可以配置设置的同步。 实际上,我们下载,安装,启动并选择共享的应用程序文件夹,因为git clone将为我们创建自己的文件夹。

我使用的插件如下:

图片

为PC安装git
使用ctrl + shift +`or terminal> new terminal在VScode中打开一个控制台

撤退:

在Windows控制台中,俄语字符不好用,因此无需打开文件>首选项>设置,在字段中输入terminal.integrated.shellArgs.windows,然后单击

图片

并添加“ terminal.integrated.shellArgs.windows”行:[“ -NoExit”,“ / c”,“ chcp 65001”],

图片


重复命令以从github下载文件:

 git clone https://github.com/ReTWi/mysuperapp.git 

在VScode中,单击文件>打开文件夹,然后打开我们应用程序的文件夹。

使用相同的简单服务器代码创建server.js文件:

 const http = require('http'); const hostname = 'localhost'; const port = 80; const server = http.createServer((req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('Hello World!\n'); }); server.listen(port, hostname, () => { console.log(`Server running at http://${hostname}:${port}/`); }); 

安装nodemon以在对代码进行更改时自动重新启动服务器:

 npm i nodemon -g 

i-安装简称
g-全局安装(可在控制台中使用),而不仅仅是我们的应用程序。

运行命令:

 nodemon server.js 

在浏览器中打开localhost :80 /或仅在localhost :80中打开Hello World。

现在是时候查看我们的PC链> Github>托管了。

下载Github桌面以获得更大的便利,连接您的github帐户,然后单击添加本地存储库文件并指定我们的应用程序的目录。

在应用程序中,我们看到与从Github下载的版本(我们添加了server.js)相比所做的更改:

图片

单击“提交到母版”>“推送原点”,从而将文件从PC下载到Github。

图片

我们在浏览器中转到我们的github帐户,并查看下载的server.js文件:

图片

让我们多做一些练习,在VScode中,我们替换行“ res.end('Hello World!\ N');” 改为“ res.end('OmNomNom');”。 我们将看到服务器本身重新启动:

图片

我们将在浏览器中签到,然后在此处查看我们“ OmNomNom”所做的更改。

桌面github也将向我们展示我们已更改了这一行:

图片

再次,单击提交给master> push origin将文件发送到github。

切换到托管命令行。

如果应用程序仍在运行(ctrl + c),我们将停止它。

使用以下命令下载我们更新的应用程序:

 git config credential.helper store git pull 

第一个将保存我们的数据,这样您就不必不断输入用户名和密码。 将来,git pull对我们来说足够了。

安装pm2-与nodemon类似,仅用于托管:

 npm i pm2 -g 

让我们使用pm2启动应用程序,它将通过托管上的下一个git pull重新启动服务器:

 pm2 start server.js --watch 

在我们的外部IP云上打开浏览器,然后查看“ OmNomNom”。

因此,我们关闭了应用程序及其在主机上的快速部署的工作链。

我们在本地主机和主机上为HTTPS创建临时SSL证书


我们转到网站zerossl.com

图片

在域ip ...字段中,首先输入localhost,单击生成并通过按钮下载2个文件:

图片

我们将它们保存在ssl / localhost文件夹中的项目中。

对外部IP云重复此过程,并将其保存在ssl / myapp中。

启动更复杂的https服务器node.js


应用结构:

图片

  • 客户-我们的前端就在这里。 我有反应
  • 日志-托管日志将在此处删除
  • node_modules-node.js模块
  • 私人-您的私人文件,我将SSH访问存储在此处
  • 服务器是您的后端
  • ssl-用于在本地主机和主机上运行https的ssl证书
  • .babelrc-webpack'om应用程序响应构建设置(允许您在开发前端时使用更现代的JS)
  • .gitignore-不会移动到github的文件(git似乎看不到它们)
  • client.js-生成反应汇编的入口点
  • package.json-您正在使用的node_modeles和各种命令摘要。
  • package-lock.json-模块中的更改(据我所知,该文件将检查主机和PC上是否安装了相同的模块)。
  • pm2-watch.json-托管的pm2启动设置
  • README.md-github的封面
  • server.js-后端Node.js服务器的起点
  • webpack.config.js-反应构建配置

.gitignore


在这里,我们指出了我们不想上传到github的文件/文件夹。 他们只会在此设备上,而git不会跟踪/显示他们的更改。 打开并插入:

 /node_modules/ /logs/* # exception to the rule !logs/.gitkeep /public/react_bundle.js /public/isProd.js 

由于github不会卸载空文件夹,因此您可以在其中放入一些东西,例如,空的.gitkeep文件。 保存文件并关闭。

package.json


打开并粘贴以下内容(在//添加注释之后)

 { "name": "myapp", //    "version": "1.0.0", "description": "OmNomNom", "main": "server.js", "scripts": { "server": "pm2 start pm2-watch.json", //  npm run server     "client": "webpack -w --mode development", //  npm client    .        ,   . "client-prod": "webpack --mode production", //     production "client-analyze": "webpack --mode production --analyze true" //     production       .    }, "repository": { "type": "git", "url": "git+https://github.com/myapp/knigam.git" //     github }, "author": "rtw", "license": "UNLICENSED", //     ( ) "bugs": { "url": https://github.com/myapp/knigam.git" }, "homepage": "https://github.com/myapp/knigam.git#readme", "dependencies": { "@babel/core": "^7.2.2", //  js  frontend "@babel/plugin-transform-runtime": "^7.2.0", //  js  frontend "@babel/preset-env": "^7.3.1", //  js  frontend "@babel/preset-react": "^7.0.0", //  js  frontend "ajv": "^6.8.1", //    "babel-loader": "^8.0.5", //  js  frontend "babel-plugin-styled-components": "^1.10.0", //   styled-components "css-loader": "^2.1.0", //   webpack'om css "fastify": "^2.0.0-rc.6", //  express,      "fastify-cookie": "^2.1.6", //    "fastify-static": "^2.2.0", //     "moment": "^2.24.0", //    "pg": "^7.8.0", //    "pino": "^5.11.1", //   postgreSQL  node.js "pino-pretty": "^2.5.0", //     "react": "^16.8.1", // Frontend .      Vue.js,    .      ,     "react-dom": "^16.8.1", // React     "style-loader": "^0.23.1", //   webpack'om ,    "styled-components": "^4.1.3", // CSS in JS,           "webpack": "^4.29.3", //    "webpack-bundle-analyzer": "^3.0.3", //       "webpack-cli": "^3.2.3" //    ,     } } 

我将重点介绍为应用程序选择的两个主要框架/库:
选择Fastify作为Express.js的替代产品,因为第一个已经对htpp2提供了实验性支持,因此它正在积极开发中,在我看来,它比Express.js拥有更多的前途,后者已经变得非常缓慢并且正在以某种方式开发。 另一方面,express.js已经使用了很长时间,您可以更轻松地找到有关它的信息。

之所以选择React,是因为它让我更轻松地使用它,自己理解和尝试一切。 Vue-似乎有自己的规则和方向。 尽管在Vue中可能需要用自己的双手少写一些东西,但是由于训练的重点是优先级高,并且对于以前没有编程的人来说,做出反应要容易些。

我们保存package.json文件,并使用以下命令安装在依赖项中指定的所有模块:

 npm i 

我们将有一个node_modules文件夹,其中将包含我们应用程序的所有模块。

客户端-空文件夹
logs-.gitkeep文件位于其中,因此该文件夹迁移到托管,并且日志成功落入该位置。 在开发过程中,我们会将所有内容输出到控制台。

公开的


我们网站的静态文件将位于此处,其中包含图片,网站图标等。
让我们关注两个文件:
index.html:

 <!DOCTYPE html> <html> <head> <base href="/" /> <meta charset="UTF-8" /> <title>MyApp</title> </head> <body> <div id="cookies">    react_bundle   </div> <noscript >:       Javscript</noscript > <script src="react_bundle.js"></script> </body> </html> 

-在这里,我们加载了一个反应前端,并通过其ID呈现到标签中。

isProd.js包含一行“ module.exports = false”
由于它在.gitignore异常中,因此不可移植。 因此,我们在PC上将其设置为false,在主机上将其设置为true。 然后,我们使用此文件来了解我们当前所处的环境(开发/生产)。 在我看来,这似乎是最方便的,此外,您可以在开发过程中部分更改代码并检查生产中模块的运行情况。

ssl-本地主机和myapp文件夹中以前保存了证书

.babelrc


 { "presets": [ [ "@babel/preset-env", { "targets": { "browsers": [">0.25%", "not ie 11", "not op_mini all"] } } ], "@babel/preset-react" ], "plugins": [ "babel-plugin-styled-components", "@babel/plugin-transform-runtime" ] } 

超过0.25%的用户使用用于创建具有浏览器支持的react_bundle的设置。

client.js


 import React from 'react' import { render } from 'react-dom' render(<div>!!</div>, document.getElementById('cookies')) 


使用Cookie标签在div中渲染前端。

pm2-watch.json-允许您在主机上使用“ npm run server”命令运行服务器,并跟踪代码中的更改并自动重新引导。

webpack.config.js


反应堆应用程序生成器:

 const webpack = require('webpack'), path = require('path'), BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin module.exports = (env, argv) => { let prod = argv.mode == 'production' let config = { entry: './client.js', output: { path: path.resolve('./public'), filename: 'react_bundle.js' }, module: { rules: [ { test: /\.(js|jsx)$/, exclude: /node_modules/, loader: 'babel-loader' }, { test: /\.css$/, use: ['style-loader', 'css-loader'] } ] }, resolve: { alias: { client: path.resolve('./client/shared'), public: path.resolve('./public') } }, plugins: [ argv.analyze ? new BundleAnalyzerPlugin() : false, prod ? new webpack.optimize.AggressiveMergingPlugin() : false, new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /ru/) ].filter(Boolean), optimization: { minimize: prod ? true : false }, performance: { hints: false } } return config } 

简而言之,他打开client.js文件及其内部的所有内容,收集react_bundle并将其放置在公用文件夹中,从该文件夹中通过打开的index.html对其进行加载。

server.js


 const isProd = require('./public/isProd'), fs = require('fs'), log = require('./server/logger'), path = require('path') //   node.js,      process.on('unhandledRejection', (reason, promise) => { log.error({ reason, promise }, '  unhandledRejection') }) process.on('uncaughtException', err => { log.error({ err }, '  uncaughtException') }) // Redirect server from http port 80 to https 443 const fastifyHttp = require('fastify')({ logger: log, ignoreTrailingSlash: true }) fastifyHttp.listen(80, '::', (err, address) => { if (err) { log.error({ err, address }, '   HTTP ') } else { log.warn('Http c ') } }) // Let's Encrypt challenge fastifyHttp.get('/.well-known/acme-challenge/:file', (req, res) => { let stream = fs.createReadStream( path.join(__dirname + '/ssl/.well-known/acme-challenge/' + req.params.file) ) res.type('text/html').send(stream) }) fastifyHttp.get('/*', (req, res) => { res.redirect(301, 'https://' + req.headers.host + req.raw.url) }) fastifyHttp.get('/', (req, res) => { res.redirect(301, 'https://' + req.headers.host + req.raw.url) }) //  let fastifyOptions = { logger: log, ignoreTrailingSlash: true, http2: true } fastifyOptions.https = isProd ? { allowHTTP1: true, key: fs.readFileSync('./ssl/myapp/key.txt'), cert: fs.readFileSync('./ssl/myapp/crt.txt') } : { allowHTTP1: true, key: fs.readFileSync('./ssl/localhost/cert.key'), cert: fs.readFileSync('./ssl/localhost/cert.pem') } const fastify = require('fastify')(fastifyOptions) fastify.listen(443, '::', (err, address) => { if (err) { log.error({ err, address }, '   ') } else { log.warn( `   ${ isProd ? '' : ' ' }` ) } }) //  fastify.setSchemaCompiler(schema => { return ajv.compile(schema) }) //  fastify fastify.setErrorHandler((err, req, res) => { log.error({ err, req }, 'fastify errorHandler') //     if (err.validation) { return res.send({ error: '   ' }) } else { return res.send({ error: ' errorHandler' }) } }) //   fastify.register(require('fastify-static'), { root: path.join(__dirname, './public') }) //  fastify.register(require('fastify-cookie'), err => { if (err) log.error({ err }, 'fastify-cookie') }) //       /   //       index.html,     //        api,   GET /api/userdata fastify.setNotFoundHandler((req, res) => { res.sendFile('index.html') }) // Routes fastify.register( async openRoutes => { //    openRoutes.register(require('./server/api/open')) openRoutes.register(async withSession => { //         //    , : ///withSession.addHook('preHandler', async (req, res) => { // if (!(await sessionManagerIsOk(req, res))) return // }) withSession.register(require('./server/api/with_session')) }) }, { prefix: '/api' } //    ) 

服务器文件夹


后端和所有方式都在这里。
logger.js-根据环境的不同,isProd会记录到控制台或错误日志中。

 'use strict' const pino = require('pino'), isProd = require('../public/isProd') let logOptions = isProd ? { level: 'warn', //   timestamp: () => { return ',"time":"' + new Date() + '"' } } : { level: 'warn', prettifier: require('pino-pretty'), prettyPrint: { levelFirst: true, translateTime: true } } let dest = isProd ? pino.destination('./logs/errors.log') : pino.destination(1) let log = pino(logOptions, dest) module.exports = log 

服务器/ API /
open.js-在此处添加我们的路径。

 'use strict' module.exports = function(fastify, options, next) { fastify.route({ method: 'GET', url: '/', handler: async (req, res) => { res.send('api / route') } }) fastify.route({ method: 'GET', url: '/hi', handler: async (req, res) => { res.send('api / route hi') } }) next() } 

在Localhost上设置并检查所有内容之后,我们只需将所有内容上传到github,然后从git pull到托管。 在托管上需要做的所有事情就是使用“ npm i”命令安装node.js模块并创建isProd.js文件。

SSL自动更新


当您购买域名并将其绑定到IP云时,这是REG.RU说明示例 ,您可以在服务器上安装自动更新的免费SSL,以使站点可以通过https工作。

我们的服务器在没有nginx的情况下可以工作。 将来我们可能会需要它作为负载均衡器或速度更快的HTTP服务器来分发静态文件,但到目前为止,我仍然没有必要。 我们还不需要负​​载平衡,但是我还没有发现关于静态分布速度的比较。

在ssl文件夹中进行安装之前,请创建.well-known文件夹,并在其中进行acme-challenge。 原来/ opt / myapp / ssl /。众所周知/ acme挑战

要在没有nginx的具有node.js的服务器上安装自动更新的SSL ,请单击链接 。 依次在托管控制台中执行命令:

 sudo apt-get update sudo apt-get install software-properties-common sudo add-apt-repository universe sudo add-apt-repository ppa:certbot/certbot sudo apt-get update sudo apt-get install certbot sudo certbot certonly 

我们选择第二种验证方法,该方法会将特定文件放在/opt/myapp/ssl/.well-known/acme-challenge文件夹中,并在确认服务器所有者后将其删除。

我们根据请求指示我们的域,例如:“ example.com”和应用程序的ssl文件夹的路径(服务器已配置为可以提供由bot创建的文件)“ / opt / myapp / ssl”。

僵尸程序会将cron任务本身配置为在证书过期90天内更新证书。

我认为不需要花太多时间来编写所有内容,到凌晨4点,我可能已经错过了一些东西:/

掌握了此画布或阅读了一些个人观点的哈布拉人和专家的观点很有趣。 您的开发周期如何安排? 有什么地方我会误会或做错事吗?

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


All Articles