Hotwire: 没有JavaScript的Reactive Rails

Mr.Z
Written by Mr.Z on
Hotwire: 没有JavaScript的Reactive Rails

本文已获得原作者(Vladimir Dementyev)和 Evil Martians 授权许可进行翻译。原文介绍了 Rails 的最新“魔法”:Hotwire。这也是 Vladimir Dementyev 在 RailsConf 2021 上的演讲内容。

【正文如下】

引言

到传播 DHH 及其公司久经考验的新魔法的时候了,并且在超过 5 分钟的教学中学习使用 Hotwire。自从今年揭开其面纱以来,这个用于构建现代 Web 界面而似乎无需任何 JavaScript 的技术的名字就备受欢迎。这个 HTML-over-the-wire 的方案正在 Rails 世界里激发起层层涟漪:不计其数的博客文章、reddit 社区帖子、录屏视频,以及今年 RailsConf 的五个演讲,而其中会包含你所期望的内容。本文中,我想要对 Hotwire 进行彻底地解释——借助代码示例和测试策略。就像我最爱的摇滚乐队说的那样,让我们 Hotwired 来……self destruct 学习新技巧吧!【译者注:摇滚乐队 Metallica 2016 年发行了专辑《Hardwired … To Self-Destruct》,作者在这儿使用了名称的谐音】

Life is short

要概览 Hotwire 在 Rails 6 中进行使用的全貌,毋需再费其他功夫,看这个 PR 就足够了。

接下来的文章会解释上述 PR 代码——在很多细节上。它是我的 RailsConf 2021 演讲:“Frontendless Rails frontend” 的一个改编和扩展版本,所有 RailsConf 的参会者都已能够在线观看。如果你没有大会门票也不用担心:可以在这里看到其简报,而且该页面会更新演讲视频,一旦演讲可以公开发布的话。

“This is the way”

过去五年中,我一直主要在做纯后端的开发:REST 和 GraphQL APIs、WebSocket、gRPC、数据库、缓存等。

整个前端的进化像巨浪一样席卷了我:我仍然不理解为什么我们需要为每个 Web 应用都使用 reactswebpacks。传统的 HTML-first 的 Rails 方式 才是我的方式(或者说捷径😉)。还记得那些 JavaScript 在你的应用中无需什么 MVC(或 MVVM)的日子吗?我怀念那种日子。而这些日子正在悄悄地卷土重来。

今天,我们目睹了 HTML-over-the-wire 的崛起(是的,现在它是一个实际名词了)。由 Phoenix LiveView 率先提出,StimulusReflex 系列 gems 对其发扬光大,这种基于后端通过 WebSocket 把渲染的模板推送到所有所连接的客户端的方案,在 Rails 社区获得了极大的吸引力。最终,DHH 本人于今年初把 Hotwire 呈现于世界面前。

我们是否正站在 Web 开发的另一个全球范式转变的边缘?回到服务端渲染模板的简单思维模型,这一次花费很少的精力就可实现各种花里胡哨的反应式界面吗?绞尽脑汁后,我认识到这是一厢情愿的想法:技术领域已经有太多的投资在客户端渲染的应用上而很难回头了。2020 时代的前端开发已经是一种独立的资质和有其自身需求的独立行业,我们将没办法再成为“全栈”了。

然而,HOTWire(看到那个首字母缩写吗?Basecamp 这方面很聪明,是吧?),为复杂的,或者我们应该说“错综复杂的”,航天科学一般针对浏览器的现代客户端编程,提供了一种急需的替代方案。

对于厌倦了只做 API 应用而无法掌控其呈现,以及怀念创建卓越用户体验而摆脱每周 40 小时充斥着 SQL 和 JSON 的一名 Rails 开发者而言,Hotwire 就如他所渴望能带来新鲜气息的呼吸一般,让 Web 开发重拾乐趣。

本文中,我会演示如何把 HTML-over-the-wire 哲学通过 Hotwire 用到现有的 Rails 应用上。就像我最近的大多数文章一样,我会使用 AnyCable demo 应用作为小白鼠。

这个应用很应景:交互和反应,Turbolinks 驱动,以及少量自定义 JavaScripts,还有相当好的系统测试覆盖率(这意味着我们可以进行安全地重构)。我们的 Hotwire 化 将会按照如下步骤进行:

Turbolinks 在 Rails 世界里很长时间久负盛名;其第一个主要版本在 2013 年早期发布。然而,在我的开发生涯初期,Rails 开发者有一个经验之谈:如果你的前端出毛病了,尝试一下禁用Turbolinks。让第三方 JS 库的代码跟 Turbolinks 的伪导航(参考:pushState + AJAX)兼容可不像在公园里散步那样容易。

StimulusJS 出来以后,我就不再躲避 Turbolinks 了。它通过依靠现代的 DOM mutation APIs 而从根本上解决了“连接”和“断开连接” JavaScript 的问题。Turbolinks 与 Stimulus 的代码组合,DOM 操作仅以 React-Angular 几分之一的开发成本就轻而易举产生了“SPA”般的体验。

昔日诸般好处的 Turbolinks 现在更名为 Turbo Drive,就如字面上那样它驱动了 Turbo —— Hotwire 包的核心。

如果你的应用已经使用了 Turbolinks(如我一般),切换到 Turbo Drive 不费吹灰之力。不过是一些重命名的事儿罢了。

所有你需要做的就是把package.json中的turbolinks替换为@hotwired/turbo-rails,以及把Gemfile中的turbolinks替换为turbo-rails

初始化代码稍有差异,现在的更简洁了:

- import Turbolinks from 'turbolinks';

- Turbolinks.start();
+ import "@hotwired/turbo"

注意,我们现在不需要手动启动 Turbo Drive 了(当然你可以不这么做)。

还有些“查找 & 替换”工作要做:把所有 HTML 的 data 属性的data-turbolinks更新为data-turbo

这些变更中唯一花费了我一点时间而值得一提的是处理 forms 和 redirects。之前使用 Turbolinks 时,我使用的是 remote forms(remote: true)和 Redirection concern 来响应以 JavaScript 模板。Turbo Drive 已经内置了对表单拦截的支持,所以remote: true就不再需要了。然而,事实证明 redirection 代码必须进行更新,或者更精确地说,是 redirection status code

- redirect_to workspace
+ redirect_to workspace, status: :see_other

使用有些晦涩的 See Other HTTP response code (303) 是一个聪明的选择:它允许 Turbo 依赖原生 Fetch API 的 redirect: "follow" 选项,这样在表单提交后你就不必明确发起另一个请求以获取新内容。根据其规范,“if status is 303 and request’s method is not GET or HEAD”,GET 请求必须被自动执行。把这个跟 “if status is 301 or 302 and request’s method is POST” 比较一下——看到区别了吗?

其他的 3xx 状态仅适用于 POST 请求,而 Rails 中我们通常使用 POST, PATCH, PUT, 和 DELETE。

Framing with Turbo Frames

该来看一些真正的新东西了:Turbo Frames。

Turbo Frames 带来了页面在局部上的无缝更新(不像 Turbo Drive 是在整个页面上)。我们可以说它非常类似于<iframe>所做的,但却不用创建单独的 windows、DOM 树以及与之俱来的那些安全噩梦。

我们来看看实际的例子。

AnyCable demo 应用(称为 AnyWork)允许你创建 dashboards,其带多个 ToDo 列表和一个聊天室。用户可以与不同列表中的条目进行交互:添加、删除以及把其标注为已完成。

turbo_frames.av1-d92b50b

起初,完成和删除这些条目是通过 AJAX 请求和一个自定义 Stimulus controller 来做到的。我决定使用 Turbo Frames 来重写这部分功能以全部使用 HTML。

我们如何来解构这些 ToDo 列表项以处理单个条目的更新呢?把每个条目都转化为一个 frame!

<!-- _item.html.rb -->
<%= turbo_frame_tag dom_id(item) do %>
  <div class="any-list--item<%= item.completed? ? " checked" : ""%>">
    <%= form_for item do |f| %>
      <!-- ... -->
    <% end %>
    <%= button_to item_path(item), method: :delete %>
      <!-- ... -->
    <% end %>
  </div>
<% end %>

这里我们做了三个重要的事情:

  • 通过 helper 传递一个唯一识别符(来自ActionView的可爱的 dom_id 方法),把单个条目包裹在一个<turbo-frame> tag 之内;
  • 添加一个 HTML form,使得 Turbo 拦截表单提交并更新该 frame 的内容;以及
  • 使用button_to helper 并带 method: :delete 参数,在该代码处也创建了一个 HTML form。

现在,任何时候该 frame 内有表单提交,Turbo 都会拦截该提交,执行 AJAX 请求,从响应返回的 HTML 中提取出有相同 ID 的 frame,把其内容替换到该 frame 上。

所有上述工作没有一行自己手写的 JavaScript!

来看一下更新过后的 controller 代码:

class ItemsController < ApplicationController
  def update
    item.update!(item_params)

    render partial: "item", locals: { item }
  end

  def destroy
    item.destroy!

    render partial: "item", locals: { item }
  end
end

注意,当我们删除条目时,以同样的 partial 进行了响应。但我们需要移除该条目的 HTML 节点而非更新它。要如何做呢?我们可以用一个空 frame 来响应!更新 partial 为如下:

<!-- _item.html.rb -->
<%= turbo_frame_tag dom_id(item) do %>
  <% unless item.destroyed? %>
    <div class="any-list--item<%= item.completed? ? " checked" : ""%>">
      <!-- ... -->
    </div>
  <% end %>
<% end %>

你可能会问自己一个问题:“当标注一个条目为完成时如何触发表单提交呢?”换句话说,如何让 checkbox 的状态变更来触发提交表单?我们可以通过定义一个行内事件监听器做到:

<%= f.check_box :completed, onchange: "this.form.requestSubmit();" %>

提醒:使用 requestSubmit() 而非 submit() 很重要:前者触发的“submit”事件能够被 Turbo 所拦截,而后者不能。

总结一下,我们可以放弃所有专门为此功能定制的 JS 了,只需一点 HTML 模板的更改和 controller 代码的简化。非常令人兴奋,不是么?

我们可以更进一步,把列表也转化为 frames。这会让我们在添加一个新条目时,从 Turbo Drive 的整个页面更新切换为仅特殊页面节点的更新。你大可自己尝试一下!

假设你也期望在一个条目被完成或删除的任何时候都为用户展示一个 flash 提醒(比如,“条目已被成功删除”)。我们能借助于 Turbo Frames 做到吗?听起来我们需要把 flash 消息容器包裹在一个 frame 内,并将更新后的 HTML 跟标记一起推送给该条目。这是我初始的思路,但其并不能正常工作:frame 的更新是在所创建的 frame 定义域内的。因此,我们无法更新在其外部的任何东西。

经过一番探索之后,我发现 Turbo Streams 能帮我们做到这点。

Streaming with Turbo Streams

较之于 Drive 和 Frames,Turbo Streams 完全是一项新技术。跟前两者不同,Streams 含义明确,易于理解。没有什么会自动发生,你得负责页面上何时更新何种内容。要做到这点,你需要使用特殊的<trubo-stream>元素。

来看一个 stream 元素的示例:

<turbo-stream action="replace" target="flash-alerts">
  <template>
    <div class="flash-alerts--container" id="flash-alerts">
      <!--  -->
    </div>
  </template>
</turbo-stream>

该元素负责以<template> tag 内所传输过来的新 HTML 内容替换(action="replace")DOM ID 为flash-alerts其下的节点。不管什么时候你把这样的<turbo-stream>元素下发到页面上,它都会立刻执行其 action 并销毁其自身。而在底层,它使用了 HTML 的 Custom Elements API —— 又一个为了开发乐趣(比如,更少的 JavaScript 😄)而使用现代 Web APIs 的范例。

我得说,Turbo Streams 是老式的 JavaScript 模板的一个声明式替代方案。在 2010 年代,我们写的代码类似这样:

// destroy.js.erb
$("#<%= dom_id(item) %>").remove();

而现在,我们这样写:

<!--  destroy.html.erb -->
<%= turbo_stream.remove dom_id(item) %>

目前,仅有五种可用的 actions:append、prepend、replace、remove 和 update(仅替换节点的文本内容)。我们将在下面谈论其局限性和如何克服它。

回到我们的初始问题:为 ToDo 条目的完成或删除,展示响应结果中的 flash 提醒。

我们想要一次响应结果就带有<turbo-frame><turbo-stream>两种更新。如何来做?为其添加一个新的 partial 模板:

<!-- _item_update.html.erb -->
<%= render item %>

<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

ItemsController添加一点小的改动:

+    flash.now[:notice] = "Item has been updated"

-    render partial: "item", locals: { item }
+    render partial: "item_update", locals: { item }

不幸地是,上述代码并未如预期那样正常工作:我们没有看到任何 flash 提醒。

深入研究文档之后,我发现是 Turbo 期望 HTTP 响应具有text/vnd.turbo-stream.html content type 才可激活 stream 元素。好吧,加上它:

-    render partial: "item_update", locals: { item }
+    render partial: "item_update", locals: { item }, content_type: "text/vnd.turbo-stream.html"

现在我们得到了相反的情况:flash 消息正常工作,但条目的内容不能更新了😞。是我对 Hotwire 要求太高了么?阅读了下 Turbo 的源码,我发现类似这样把 streams 和 frames 进行混合是不行的

这说明,有两种方式来实现该功能:

  • 把 streams 用在所有东西上。
  • <turbo-stream>置于<turbo-frame>内部。

第二个选项,对我而言,与在常规页面上重用 HTML partials 并以 Turbo 进行更新的想法背道而驰。所以,我选择第一个:

<!-- _item_update.html.erb -->
<%= turbo_stream.replace dom_id(item) do %>
  <%= render item %>
<% end %>

<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

任务完成。但付出了什么代价呢?我们不得不为这种用例添加一个新的模板。并且我担心在现实中的应用程序里,这种 partials 的数量会随着应用的进化而增长。

更新(2021-04-13):Alex Takitani 建议了一种更加优雅的解决方案:使用 layout 来更新 flash 内容。我们可以如下面这样把 application layout 定义为 Turbo Stream 响应:

<!-- layouts/application.turbo_stream.erb -->
<%= turbo_stream.replace "flash-alerts" do %>
  <%= render "shared/alerts" %>
<% end %>

<%= yield %>

然后,我们需要从 controller 移除相应的渲染(因为要不然 layout 就不会被用上了):

def update
     item.update!(item_params)

     flash.now[:notice] = "Item has been updated"
-
-    render partial: "item_update", locals: { item }, content_type: "text/vnd.turbo-stream.html"
   end

注意:别忘了把format: :turbo_stream添加到 controller/request specs 测试相应的请求上,以便使得 render 能正常工作。

并且把我们的_item_update partial 转换为update的 Turbo Stream 模板:

<!-- update.turbo_stream.erb -->
<%= turbo_stream.replace dom_id(item) do %>
  <%= render item %>
<% end %>

很酷,对吧?这正是 Rails 的方式!

现在,让我们转到一些实时的流广播上。

Turbo Streams 经常在实时更新的语境中被提到(且常常被用来跟 StimulusReflex 比较)。

来看看我们能够如何在 Turbo Streams 之上构建列表的同步化:

turbo_streams.av1-17e20a6

在有 Turbo 之前,我不得不添加一个自定义的 Action Cable channel 和一个 Stimulus controller 来处理广播的事情。我也需要处理消息的格式,因为必须区分对条目的删除和完成。换句话说,有不少代码要维护。

而 Turbo Streams 已经照顾好了几乎一切:turbo-rails gem 自带一个通用的Turbo::StreamChannel和一个 helper(#turbo_stream_from),用来从 HTML 中创建一个 subscription:

<!-- worspaces/show.html.erb -->
<div>
  <%= turbo_stream_from workspace %>
  <!-- ... -->
</div>

在 controller 中,我们已经有了#broadcast_new_item#broadcast_changes这样的“after action” callback 负责对更新进行播发。现在我们所有需要做的就是切换到Turbo::StreamChannel

def broadcast_changes
   return if item.errors.any?
   if item.destroyed?
-    ListChannel.broadcast_to list, type: "deleted", id: item.id
+    Turbo::StreamsChannel.broadcast_remove_to workspace, target: item
   else
-    ListChannel.broadcast_to list, type: "updated", id: item.id, desc: item.desc, completed: item.completed
+    Turbo::StreamsChannel.broadcast_replace_to workspace, target: item, partial: "items/item", locals: { item }
   end
 end

这次迁移很顺畅,几乎——因为所有检验播发(#have_broadcasted_to)的 controller 单元测试都失败了。

不幸的是,Turbo Rails 没有提供任何测试工具(?),所以我不得不自己写一个,以自己熟悉的方式

module Turbo::HaveBroadcastedToTurboMatcher
  include Turbo::Streams::StreamName

  def have_broadcasted_turbo_stream_to(*streamables, action:, target:) # rubocop:disable Naming/PredicateName
    target = target.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(target) : target
    have_broadcasted_to(stream_name_from(streamables))
      .with(a_string_matching(%(turbo-stream action="#{action}" target="#{target}")))
  end
end

RSpec.configure do |config|
  config.include Turbo::HaveBroadcastedToTurboMatcher
end

下面是我如何把这个新的匹配器用在测试上:

it "broadcasts a deleted message" do
-  expect { subject }.to have_broadcasted_to(ListChannel.broadcasting_for(list))
-    .with(type: "deleted", id: item.id)
+  expect { subject }.to have_broadcasted_turbo_stream_to(
+    workspace, action: :remove, target: item
+  )
 end

到目前为止,使用 Turbo 的实时处理进展顺利!一大堆代码都被移除了。

而我们仍然还是一行 JavaScript 代码都没有写。这也太不真实了吧?

不过是个幻梦吗?何时我会醒来?好吧,就是现在。

Beyond Turbo, or using Stimulus and custom elements

在向 Turbo 迁移的过程中,我碰到了好几个场景,使用已有的 API 是不够的,所以我最终不得不编写一些 JavaScript 代码!

场景一:向 dashboard 实时添加新的列表。这跟前面提到的列表中条目的示例有何不同?在于标记。来看一下 dashboard layout:

<div id="workspace_1">
  <div id="list_1">...</div>
  <div id="list_2">...</div>
  <div id="new_list">
    <form>...</form>
  </div>
</div>

最后一个元素总是新列表的 form 容器。不管我们何时添加新列表,它都会被插入到#new_list节点之前。还记得 Turbo Streams 仅仅支持五种 actions 不?明白问题所在了吗?下面是我起初使用的代码:

handleUpdate(data) {
  this.formTarget.insertAdjacentHTML("beforebegin", data.html);
}

要使用 Turbo Streams 实现类似的行为,我们需要添加一个 hack,在列表被通过 stream 添加之后立即把其移动到正确的位置。所以,来添加我们自己的 JavaScript 代码吧。

首先来给我们的任务一个正式的定义:“当一个新列表被 append 到 workspace 容器时,它应该出现在那个 new form 元素之前的正确位置上。”。这里的“当”意味着我们需要观察 DOM 并对变更作出反应。是不是听起来很熟悉?没错,我们已经提到过与 Stimulus 有关的 MutationObserver API!用它就对了。

幸运的是,我们不是必须编写高阶的 JavaScript 才能使用该特性;我们可以使用 stimulus-use(抱歉使用这种重言式语法。【译者注:原文是 use stimulus-use,所以作者这么说】)。Stimulus Use 是一个 Stimulus controllers 很有用的行为的集合,以简单的代码片段解决复杂的问题。我们这儿,需要 useMutation 行为。

如下的 controller 代码相当简洁,含义不言自明:

import { Controller } from "stimulus";
import { useMutation } from "stimulus-use";

export default class extends Controller {
  static targets = ["lists", "newForm"];

  connect() {
    [this.observeLists, this.unobserveLists] = useMutation(this, {
      element: this.listsTarget,
      childList: true,
    });
  }

  mutate(entries) {
    // There should be only one entry in case of adding a new list via streams
    const entry = entries[0];

    if (!entry.addedNodes.length) return;

    // Disable observer while we modify the childList
    this.unobserveLists();
    // Move newForm to the end of the childList
    this.listsTarget.append(this.newFormTarget);
    this.observeLists();
  }
}

问题就这样解决了。

来讨论下第二个边界场景:实现聊天室功能。

我们有一个非常简单的聊天室附在每个 dashboard 上:用户可以发送临时消息(不会被存储到任何地方)和实时接收它们。消息具有依赖于上下文的不同外观:自己的消息有绿色边框,靠左;其他消息则是灰色,靠右。而我们是向每个所连接的客户端播发相同的 HTML。要如何使得用户看到这种区别呢?这对于聊天室类的应用是一个很常见的问题,且一般而言,它通过要么向每个用户 channel 发送个性化的 HTML,要么增强所收到的 HTML 在客户端来解决。我更喜欢第二种,所以来实现它吧。

要把当前用户的信息传递给 JavaScript,我使用 meta tags:

<!-- layouts/application.html.erb -->
<head>
  <% if logged_in? %>
    <meta name="current-user-name" content="<%= current_user.name %>" data-turbo-track="reload">
    <meta name="current-user-id" content="<%= current_user.id %>" data-turbo-track="reload">
  <% end %>
  <!-- ... -->
</head>

和一个小的 JS helper 来获取这些 values:

let user;

export const currentUser = () => {
  if (user) return user;

  const id = getMeta("id");
  const name = getMeta("name");

  user = { id, name };
  return user;
};

function getMeta(name) {
  const element = document.head.querySelector(
    `meta[name='current-user-${name}']`
  );
  if (element) {
    return element.getAttribute("content");
  }
}

要播发聊天室消息,我们将会使用Turbo::StreamChannel

def create
  Turbo::StreamsChannel.broadcast_append_to(
    workspace,
    target: ActionView::RecordIdentifier.dom_id(workspace, :chat_messages),
    partial: "chats/message",
    locals: { message: params[:message], name: current_user.name, user_id: current_user.id }
  )
  # ...
end

下面是初始的chat/message模板:

<div class="chat--msg">
  <%= message %>
  <span data-role="author" class="chat--msg--author"><%= name %></span>
</div>

以及前述根据当前用户赋予不同样式的 JS 代码,这些代码我们很快就要去掉:

// Don't get attached to it
appendMessage(html, mine) {
  this.messagesTarget.insertAdjacentHTML("beforeend", html);
  const el = this.messagesTarget.lastElementChild;
  el.classList.add(mine ? "mine" : "theirs");

  if (mine) {
    const authorElement = el.querySelector('[data-role="author"]');
    if (authorElement) authorElement.innerText = "You";
  }
}

现在,当 Turbo 负责更新 HTML 时,我们需要做点不同的事。当然,useMutaion也会在这里被用到。并且这有可能是我将用在现实项目上的。然而,我今天的目标是演示以不同的方式来解决问题。

还记得我们一直在谈论 Custom Elements(哦,那是好几页之前了,抱歉,这说明我们阅读太久了)?它正是令 Turbo 之所以强大的 Web API。我们干嘛不用呢!

让我先分享一个更新后的 HTML 模板:

<any-chat-message class="chat--msg" data-author-id="<%= user_id %>>
  <%= message %>
  <span data-role="author" class="chat--msg--author"><%= name %></span>
</any-chat-message>

我们只添加了data-author-id属性,并把<div>替换为自定义 tag ——<any-chat-message>

现在来对 custom element 进行注册:

import { currentUser } from "../utils/current_user";

// This is how you create custom HTML elements with a modern API
export class ChatMessageElement extends HTMLElement {
  connectedCallback() {
    const mine = currentUser().id == this.dataset.authorId;

    this.classList.add(mine ? "mine" : "theirs");

    const authorElement = this.querySelector('[data-role="author"]');

    if (authorElement && mine) authorElement.innerText = "You";
  }
}

customElements.define("any-chat-message", ChatMessageElement);

大功告成!现在当一个新的<any-chat-message>元素被添加到页面时,如果它来自于当前用户就自动更新自己。而且甚至我们为此都不再需要 Stimulus 了!

你可以在这个 PR 中找到本文有关的全部源代码。

所以,那么零 JavaScript 的 Reactive Rails 到底存在吗?并不。我们移除了很多 JS 代码,但最后不得不用一些新东西来替代。这些新东西跟之前的有所区别:它更加,我得说,实用主义。它也更加高阶,需要对 JavaScript 以及最新浏览器 APIs 有很好的了解,这肯定是要权衡考虑的。

附:我对 CableReady 和 StimulusReflex 也有一个类似的 PR。你可以把它跟 Hotwire 的这个 PR 进行比较,在 Twitter 上与我们分享你的观点。

Rating:
Mr.Z
About Mr.Z A Chinese software engineer living and working in Chengdu. I love Creating the future in digital worlds, big and small.

Comments

comments powered by Disqus