禁欲网:Go和JS跳蚤市场原型

屏幕截图


大家好,我想分享我对现代Web应用程序的想法。 例如,考虑为漫画设计一个公告栏。 从某种意义上说,所讨论的产品是为极客和同情者设计的,可让您在界面中展现自由。 相反,在技术组件中,必须注意细节。


实际上,我对漫画一无所知,但我喜欢跳蚤市场,尤其是论坛形式的跳蚤市场,零时流行。 因此,以下结论所依据的假设(可能是错误的)仅是一种-与应用程序交互的主要类型是查看,其次是发布公告和讨论。


我们的目标是创建一个简单的应用程序,而无需 技术知识 然而,多余的哨声与现代现实相符。 我要达到的主要要求是:


  1. 服务器端:


    a)执行存储,验证,发送用户数据到客户端的功能
    b)以上操作消耗了可接受的资源量(时间,包括)
    c)保护应用程序和数据不受流行的攻击媒介的侵害
    d)它具有用于第三方客户端和服务器间交互的简单API
    e)跨平台,易于部署


  2. 客户端:


    a)提供创建和使用内容的必要功能
    b)该界面便于常规使用,任何操作的最小路径,每个屏幕的最大数据量
    c)由于无法与服务器通信,因此在这种情况下所有可用功能均可用
    d)界面显示状态和内容的当前版本,而无需重启和等待
    d)重新启动应用程序不会影响其状态
    f)如果可能,请重用DOM元素和JS代码
    g)我们不会在运行时使用第三方库和框架
    h)布局是可访问性,解析器等的语义。
    i)使用URL和键盘可访问主要内容



在我看来,合乎逻辑的要求以及大多数现代应用程序都在某种程度上满足了这些条件。 让我们看看发生了什么(在文章末尾链接到源和演示)。


警告:
  • 我想向演示中未经授权的图片的未知作者以及GösseG.,Prozorovskaya B. D.和出版社“佛罗伦萨Pavlenkov图书馆”道歉,以使用摘自“悉达多”作品的节选。
  • 作者不是真正的程序员,如果您不知道自己在做什么,我不建议您使用此项目中使用的代码或技术。
  • 我为代码的样式表示歉意;它本来可以更可读,更明显地编写,但这并不有趣。 正如他们所说,这是为灵魂和朋友设计的项目。
  • 我也对识字率表示歉意,尤其是英文本。 年从哈特说话
  • 提出的原型的性能在[铬70; linux x86_64; 1366x768],我将非常感谢其他平台和设备的用户收到错误消息。
  • 这是一个原型,是一个可供讨论的主题-方法和原则,我要求对实施和美学方面的所有批评都应附有论据。

伺服器


服务器的语言是golang。 一种简单,快速的语言,带有出色的标准库和文档...有点烦人。 最初的选择落在elixir / erlang上,但是由于我已经(相对)知道go了,因此决定不使其复杂化(并且必需的软件包仅适用于go)。


不鼓励在go-community中使用Web框架(理所当然,这是值得承认的),我们选择了一个折衷方案,并使用labstack / echo微框架 ,从而减少了例程的数量,在我看来,这并没有损失太多性能。


我们使用tidwall / buntdb作为数据库。 首先,内置解决方案更加方便并降低了管理费用,其次,内存+键/值- 时尚的 快速且不需要缓存。 我们以JSON存储并提供数据,仅在更改时进行验证。


在第二代i3上,内置记录器会显示不同请求的执行时间,范围从0.5到10ms。 在同一台计算机上运行wrk也会显示出满足我们目的的足够结果:


➜ comico git:(master) wrk -t2 -c500 -d60s http://localhost:9001/pub/mtimes Running 1m test @ http://localhost:9001/pub/mtimes 2 threads and 500 connections Thread Stats Avg Stdev Max +/- Stdev Latency 20.74ms 16.68ms 236.16ms 72.69% Req/Sec 13.19k 627.43 15.62k 73.58% 1575522 requests in 1.00m, 449.26MB read Requests/sec: 26231.85 Transfer/sec: 7.48MB 

 ➜ comico git:(master) wrk -t2 -c500 -d60s http://localhost:9001/pub/goods Running 1m test @ http://localhost:9001/pub/goods 2 threads and 500 connections Thread Stats Avg Stdev Max +/- Stdev Latency 61.79ms 65.96ms 643.73ms 86.48% Req/Sec 5.26k 705.24 7.88k 70.31% 628215 requests in 1.00m, 8.44GB read Requests/sec: 10454.44 Transfer/sec: 143.89MB 

项目结构


comico /模型包分为三个文件:
model.go-包含数据类型和一般功能的描述:创建/更新(buntdb不区分这些操作,我们手动检查记录的存在),验证,删除,获取一条记录和获取列表;
rules.go-包含针对特定类型和日志记录功能的验证规则;
files.go-处理图像。
Mtimes类型将有关剩余类型的最后更改的数据存储在数据库中,从而通知客户端哪些数据已更改。


comico / bd软件包包含用于与数据库交互的通用功能:创建,删除,选择等。Buntdb将所有更改以文本格式保存到文件中(在本例中为每秒一次),这在某些情况下很方便。 db文件未编辑,如果事务成功,更改将添加到末尾。 我所有破坏数据完整性的尝试均未成功,在最坏的情况下,最后一秒钟的更改都丢失了。
在我们的实现中,每种类型都对应于一个单独文件中的单独数据库(日志仅存储在内存中,并且在重新启动后重置为零)。 这主要是由于备份和管理的便利,还有一个小优点-用于编辑的开放式事务仅阻止对一种类型数据的访问。
可以使用另一个数据库(例如SQL)轻松地用类似的软件包替换此软件包。 为此,只需实现以下功能:


 func Delete(db byte, key string) error func Exist(db byte, key string) bool func Insert(db byte, key, val string) error func ReadAll(db byte, pattern string) (str string, err error) func ReadOne(db byte, key string) (str string, err error) func Renew(db byte, key string) (err error, newId string) 

comico / cnst软件包包含所有软件包中必不可少的常量(数据类型,操作类型,用户类型)。 此外,此软件包还包含所有人类可读的消息,我们的服务器将使用这些消息来响应外界。


漫画/服务器软件包包含路由信息。 而且,仅配置了几行(感谢Echo开发人员),使用JWT,CORS,CSP标头,记录器,静态分发,gzip,ACME自动证书等的授权。


API入口点


网址资料内容描述
获取/发布/(商品|帖子|用户| cmnts |文件)--获取一系列相关的公告,帖子,用户,评论,文件
获取/发布/ mtimes--获取每种数据类型的最后更改时间
发布/发布/登录{id *:登录名,通过*:密码}返回JWT令牌及其持续时间
发布/发布/通过{id *,通过*}如果数据正确,则创建一个新用户
放/ api /通过{id *,通过*}密码更新
张贴|放/ api /商品{id *,auth *,title *,类型*,price *,text *,图片:[],表格:{key:value}}制作/更新广告
发布| put / api / posts{id *,auth *,title *,type *,text *}创建/更新论坛帖子
发布|放置/ api /用户{id *,标题,类型,状态,抄写员:[],忽略:[],表格:{键:值}}创建/更新用户
发布/ api / cmnts{id *,auth *,所有者*,键入*,收件人,文本*}评论创建
删除/ api /(商品|帖子|用户| cmnts)/ [id]--删除ID为的条目
获取/ API /活动--更新当前用户传入注释的上次阅读时间
获取/ api /(订阅|忽略)/ [标签]--向订阅列表中的用户添加或删除(如果有)标签/忽略
帖子/ api /上传/(商品|用户)多部分(名称,文件)上传照片广告/用户头像

*-必填项
api-需要授权,pub-否


对于与上述请求不匹配的get请求,服务器将在目录中查找静态文件(例如/ img / *-图像,/ index.html-客户端)。
如果成功,任何api点都将返回200响应代码,对于错误将返回400或404,并在必要时返回一条短消息。
访问权限很简单:创建条目可用于授权用户,编辑作者和主持人,管理员可以编辑和任命主持人。
该API配备了最简单的防破坏功能:将操作以及用户ID和IP一起记录下来,并且在频繁访问的情况下,返回错误,要求您稍等(对密码猜测很有用)。


顾客


我喜欢反应式Web'a的概念,我认为大多数现代网站/应用程序应在该概念的框架内完成,或者完全静态地完成。 另一方面,一个拥有数百万字节JS代码的简单站点只能令人沮丧。 我认为,这个问题(不仅是问题)可以由斯维尔特解决。 该框架(或用于构造反应式接口的语言)在必要的功能上并不逊于Vue,但具有不可否认的优势-组件被编译为Vanilla JS,既减小了包的大小,又减小了虚拟机上的负载(bundle.min.js.gz按照今天的标准,我们的跳蚤市场很小,只有24KB)。 详细信息可以在官方文档中找到。


我们为跳蚤市场的客户选择了SvelteJS跳蚤市场,我们希望Rich Harris一切顺利,并希望项目进一步发展!


PS我不想得罪任何人。 我确信每个专家和每个项目都有自己的工具。


客户/数据


网址


我们用于导航。 我们将不会模拟多页文档;相反,我们使用具有查询参数的哈希页。 对于过渡,可以使用不带js的常规<a>。


这些部分对应于以下数据类型: /#goods/#posts/#users
参数:?Id = record_id ,?Page = page_number ,?Search = search_query


一些例子:


  • /#posts?id = 1542309643&页面= 999&搜索= {auth:anon} -部分帖子 ,post id- 1542309643 ,评论页面-999 ,搜索查询- {auth:anon}
  • /#goods?页面= 2&搜索= siddhartha-商品 ,节页面-2 ,搜索查询-siddhartha
  • /#goods?search = wer {key:value} t截面商品 ,搜索查询-包括搜索广告标题或文本中的wert子字符串以及广告表格部分的key属性中的子字符串
  • /#goods?搜索= {型号:100,显示:256} -我认为类推在这里很清楚

我们的实现中的解析和URL生成功能如下所示:


 window.addEventListener('hashchange', function() { const hash = location.hash.slice(1).split('?'), result = {} if (!!hash[1]) hash[1].split('&').forEach(str => { str = str.split('=') if (!!str[0] && !!str[1]) result[decodeURI(str[0]).toLowerCase()] = decodeURI(str[1]).toLowerCase() }) result.type = hash[0] || 'goods' store.set({ hash: result }) }) function goto({ type, id, page, search }) { const { hash } = store.get(), args = arguments[0], query = [] new Array('id', 'page', 'search').forEach(key => { const value = args[key] !== undefined ? args[key] : hash[key] || null if (value !== null) query.push(key + '=' + value) }) location.hash = (type || hash.type || 'goods') + (!!query.length ? '?' + query.join('&') : '') } 

API


要与服务器交换数据,我们将使用访存API。 要以较短的间隔下载更新的记录,我们向/ pub / mtimes发出请求,如果任何类型的上次更改时间与本地时间不同,我们将加载此类型的列表。 是的,可以通过SSE或WebSockets和增量加载来实现更新通知,但是在这种情况下,我们可以不用它。 我们得到了什么:


 async function GET(type) { const response = await fetch(location.origin + '/pub/' + type) .catch(() => ({ ok: false })) if (type === 'mtimes') store.set({ online: response.ok }) return response.ok ? await response.json() : [] } async function checkUpdate(type, mtimes, updates = {}) { const local = store.get()._mtimes, net = mtimes || await GET('mtimes') if (!net[type] || local[type] === net[type]) return const value = updates['_' + type] = await GET(type) local[type] = net[type]; updates._mtimes = local if (!!value && !!value.sort) store.set(updates) } async function checkUpdates() { setTimeout(() => checkUpdates(), 30000) const mtimes = await store.GET('mtimes') new Array('users', 'goods', 'posts', 'cmnts', 'files') .forEach(type => checkUpdate(type, mtimes)) } 

对于过滤和分页,我们基于导航数据使用Svelte的计算属性。 计算值的方向为: 项目 (来自服务器的记录数组)=> ignoreItems (基于当前用户的忽略列表过滤的记录)=> scribedItems (如果已激活,则根据订阅列表过滤记录)=> curItemcurItems (计算当前记录) => filteredItems (根据搜索查询过滤记录,如果只有一条记录,则过滤评论)=> maxPage (基于每页12条记录/评论计算页面数)=> pagedItem (返回带有 根据当前页码发布/评论)。


注释和图像( comment_images )是分别计算的,并按类型和所有者记录分组。


计算会自动进行,并且只有在关联数据发生更改时,中间数据才会一直存储在内存中。 在这方面,我们得出不愉快的结论-对于大量信息和/或其频繁更新,可能会花费大量资源。


快取


根据制作脱机应用程序的决定,我们在localStorage中实现记录的存储以及状态的某些方面,在CacheStorage中实现图像文件的存储。 使用localStorage非常简单,我们同意前缀为“ _”的属性会在更改后重新引导时自动保存和恢复。 然后我们的解决方案可能看起来像这样:


 store.on('state', ({ changed, current }) => { Object.keys(changed).forEach(prop => { if (!prop.indexOf('_')) localStorage.setItem(prop, JSON.stringify(current[prop])) }) }) function loadState(state = {}) { for (let i = 0; i < localStorage.length; i++) { const prop = localStorage.key(i) const value = JSON.parse(localStorage.getItem(prop) || 'null') if (!!value && !prop.indexOf('_')) state[prop] = value } store.set(state) } 

文件要复杂一些。 首先,我们将使用来自服务器的所有相关文件(带有创建时间)的列表。 更新此列表时,我们将其与旧值进行比较,将新文件放入CacheStorage,然后从其中删除过时的文件:


 async function cacheImages(newFiles) { const oldFiles = JSON.parse(localStorage.getItem('_files') || '[]') const cache = await caches.open('comico') oldFiles.forEach(file => { if (!~newFiles.indexOf(file)) { const [ id, type ] = file.split(':') cache.delete(`/img/${type}_${id}_sm.jpg`) }}) newFiles.forEach(file => { if (!~oldFiles.indexOf(file)) { const [ id, type ] = file.split(':'), src = `/img/${type}_${id}_sm.jpg` cache.add(new Request(src, { cache: 'no-cache' })) }}) } 

然后,您需要重新定义获取行为,以便无需连接服务器即可从CacheStorage中获取文件。 为此,您必须使用ServiceWorker。 同时,我们将配置其他要缓存的文件,以便在服务器外部进行操作:


 const CACHE = 'comico', FILES = [ '/', '/bundle.css', '/bundle.js' ] self.addEventListener('install', (e) => { e.waitUntil(caches.open(CACHE).then(cache => cache.addAll(FILES)) .then(() => self.skipWaiting())) }) self.addEventListener('fetch', (e) => { const r = e.request if (r.method !== 'GET' || !!~r.url.indexOf('/pub/') || !!~r.url.indexOf('/api/')) return if (!!~r.url.lastIndexOf('_sm.jpg') && e.request.cache !== 'no-cache') return e.respondWith(fromCache(r)) e.respondWith(toCache(r)) }) async function fromCache(request) { return await (await caches.open(CACHE)).match(request) || new Response(null, { status: 404 }) } async function toCache(request) { const response = await fetch(request).catch(() => fromCache(request)) if (!!response && response.ok) (await caches.open(CACHE)).put(request, response.clone()) return response } 

它看起来有些笨拙,但是可以执行其功能。


客户端/界面


组件结构:
index.html | main.js
== header.html-包含徽标,状态栏,主菜单,下部导航菜单,评论提交表单
== aside.html-是所有模式组件的容器
==== goodForm.html-用于添加和修改广告的表单
==== userForm.html-编辑当前用户的表单
====== tableForm.html-用于输入表格数据的表单的一部分
==== postForm.html-论坛帖子的表单
==== login.html-登录/注册表格
==== activity.html-显示发给当前用户的评论
==== goodImage.html-查看主要和其他照片广告
== main.html-主要内容的容器
==== goods.html-列表或单张公告卡
==== users.html-用户相同
==== posts.html-我认为这很清楚
==== cmnts.html-当前帖子的评论列表
====== cmntsPager.html-注释分页


  • 在每个组件中,我们都尽量减少html标签的数量。
  • 我们仅将类用作状态指示符。
  • 我们采用了与商店相似的功能(可以通过在组件上添加前缀“ $”来直接从组件中使用精简的商店属性和方法)。
  • 大多数功能都希望发生用户事件或某些属性发生变化,操纵状态数据,并将其工作结果保存回状态并结束。 因此,实现了代码的小的一致性和可扩展性。
  • 对于过渡和其他UI事件的明显速度,我们尽可能地将对在后台发生的数据的操作和与接口相关联的操作分开,这些操作又使用当前的计算结果,并在必要时进行重建,其余的将由框架执行。
  • 为了防止输入丢失,将填写的表单数据存储在localStorage中。
  • 在所有组件中,我们使用不可变模式,在这种模式下,仅当接收到新链接时才考虑更改属性对象,而不管字段中的更改如何,因此尽管代码量略有增加,但仍可以稍微加快我们的应用程序的速度。

客户/管理


要使用键盘进行控制,我们使用以下组合:
Alt + s / Alt + a-向前/向后切换记录页面,对于一个记录,则切换注释页面。
Alt + w / Alt + q-移至下一个/上一个记录(如果有),在列表模式下工作,单个记录和图像视图
Alt + x / Alt + z-向下/向上滚动页面。 在图像视图中,向前/向后切换图像
退出 -关闭模式窗口,如果已打开,则返回列表,如果单个条目已打开,则以列表模式取消搜索查询
Alt + c-取决于当前模式,专注于搜索或注释字段
Alt + v-启用/禁用单个广告的照片查看模式
Alt + r-打开/关闭授权用户的传入评论列表
Alt + t-切换浅色/深色主题
Alt + g-广告列表
Alt + u-用户
Alt + P-论坛
我知道在许多浏览器中,这些组合都是由浏览器本身使用的,但是对于我的Chrome浏览器,我无法提供更方便的方法。 我会很高兴您的建议。


除了键盘之外,您当然可以使用浏览器控制台。 例如, store.goBack()store.nextPage()store.prevPage()store.nextItem()store.prevItem()store.search(stringValue)store.checkUpdate('goods'||' users'||'posts'||'files'||'cmnts') -顾名思义; store.get()。注释store.get()._图像 -返回分组的文件和注释; store.get()。ignoreItemsstore.get()。scribedItems是您忽略和跟踪的记录列表。 所有中间数据和计算数据的完整列表可从store.get()获得 。 我认为没有人会很需要这样做,但是例如,从控制台筛选用户并删除记录对我来说似乎很方便。


结论


您可以在这里结束对项目的了解;您可以在源代码中找到更多详细信息。 结果,我们得到了一个相当快速而紧凑的应用程序,在大多数验证器,安全性,速度,可用性等检查器中,它都显示出良好的结果,而无需进行有针对性的优化。
我想知道社区的意见,组织原型中使用的应用程序的方法是多么合理,可能会有什么陷阱,您将以根本不同的方式实现什么?
源代码,示例安装说明和演示此处 (请 破坏 在《刑法》的框架内进行测试)。


后记。 结论是一点点商机。 告诉我,达到这样的水平,真的有可能开始赚钱编程吗? 如果不是,首先要寻找的是,如果是,请告诉我他们现在在哪里寻找相似堆栈上的有趣作品。 谢谢啦


后记。 有关金钱和工作的更多信息。 您如何看待这个想法:假设一个人愿意为他准备一个有趣的项目以赚取任何薪水,但是,如果支付的价格大大低于市场,雇主的竞争对手将公开提供有关任务及其支付的数据(可访问性和评估绩效质量的代码是可取的)可以为执行任务提供很多资金(如果更高)-许多执行者将能够以较低的价格提供服务。 这样的方案在某些情况下会更好地公平地平衡市场(IT)吗?

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


All Articles