這幾天當插畫師的老婆要求我為她找一些設計資源並且聚合起來,方便她去查閱和使用。本質上這種東西就是一個導航站是很簡單的,有很多建站工具、靜態頁面生成工具,甚至只需要“手動”做個 html 頁面也就可以滿足。
但是職業習慣(造輪子),不想因為是個簡單的需求就去做一個簡單的東西。既然是個需求我覺得也是個方向,如果這個資源站可以是一個有價值和品質的產品呢?那就需要花些心思。
按我希望的資源站本身能擁有聚合功能,比如我們喜歡某些資源可以自己提交,也可以將站內的資源收為已用。有點像pinterest、花瓣網,只不過是它的資源的針對性是有差異的。並且也希望它在桌面端、移動端都有所表現,因為資源本身就要隨時隨時可以使用,也便於應用,但按正常這樣下去半個月沒了。
可是老婆催的緊,希望我半天完成“交付使用”。
那麼這次我們就一起來打造一個基於實時數據庫 + graphql 為技術核心的資源站產品。先用半天時間實現出有自適應能力的前端、支持 pwa、打包為 android、ios、桌面端、ssr、spa,後端支持實時數據查詢。而這一切內容只需要兩項核心技術的搭配。
主要技術棧:
- hasura Graphql 基於 Postgres 的即時 GraphQL
作為第一家GraphQL-as-a-Service公司,Hasura推出了其開源GraphQL引擎,這是目前唯一可立即將GraphQL-as-a-Service添加到現有基於Postgres應用程序中的解決方案
- Quasar (基於 vuejs 的一個真正框架)
今天主要的目標是將所有點先串起來,然後實現一個基礎,先將最核心的資源內容展現出來。目標效果是這樣的:
那麼我們開始實踐:
1、Hasura GraphQL Engine 服務搭建
要使用Hasura GraphQL引擎,需要:
- 運行Hasura GraphQL引擎並訪問Postgres數據庫
- 使用連接到Hasura GraphQL引擎的Hasura控制檯(一個管理UI)來幫助構建模式並運行GraphQL查詢
image.png
Hasura控制檯用於查詢和更新數據庫,並生成對應的
query
mutation
delete
insert
update
subscription
基於 docker-compose 編排文件,一個文件搞定全部數據服務環境
<code>version: '3.6'
services:
postgres:
image: postgres:12
restart: always
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgrespassword
graphql-engine:
image: hasura/graphql-engine:v1.2.0-beta.2
ports:
- "8080:8080"
depends_on:
- "postgres"
restart: always
environment:
HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
## uncomment next line to set an admin secret
# HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey
volumes:
db_data:
/<code>
運行 docker-compose
<code>docker-compose up -d
/<code>
進入Hasura控制檯訪問 [http://localhost:8080/console/api-explorer](http://localhost:8080/console/api-explorer)
image.png
因為數據庫已經建立表過一些表,Hasura 服務是可以自動識別和追蹤的,識別之後會生成如上圖所見各表的 graphql 查詢 schema,這樣所有的基於 graphql 的增刪改查、級聯查詢就都已經就緒,相當於你的 api 服務基本已經完成,如果業務層面主要是數據形式操作的話,服務系統就完成了。
2、建立客戶端由於我們的目標最終是形成一個可供多端使用的資源站系統,需要 移動端 app、桌面端、瀏覽器端都能夠有對應的終端,這裡選用了一款框架 https://quasar.dev/ ,它可以一套 vuejs 代碼生成所有終端,並且它有獨立的 cli 環境、完善的UI組件,非常實用。
image.png
好,現在開始建立客戶端開發環境和創建項目工程
<code># Node.js >= 8.9.0 is required.
$ yarn global add @quasar/cli
quasar create picker-client
/<code>
完成後,修改 package 的>
<code>// package.json
"scripts": {
"dev": "quasar dev",
"build": "quasar build",
"build:pwa": "quasar build -m pwa"
}
/<code>
quasar 和普通的 vuecli 建立的項目有些不同,它提供了一個套整體解決方案,插件以及它的相關環境是在一個 /quasar.conf.js 文件中進行配置,它的結構如下:
<code>module.exports = function (ctx) {
console.log(ctx)
// Example output on console:
{
dev: true,
prod: false,
mode: { spa: true },
modeName: 'spa',
target: {},
targetName: undefined,
arch: {},
archName: undefined,
debug: undefined
}
// context gets generated based on the parameters
// with which you run "quasar dev" or "quasar build"
}
/<code>
更具體的配置可以參考 https://quasar.dev/quasar-cli/quasar-conf-js
運行
<code>npx quasar dev
/<code>
其它發佈模式可參考:
image.png
上述按 cli 指引就已經建立好基礎的前端工程,但由於我們的需求是基於 graphql 的客戶端,並且需要前端工程是基於 Typescript編寫, 所以在結構上面還有一些調整。下面詳細介紹:
Typescript 支持
1、首先增加 typescript 支持,在quasar.config.js 中 增加 supoortTS: true2、創建 tsconfig.json
<code>{
"compilerOptions": {
"allowJs": true,
"sourceMap": true,
"target": "es6",
"strict": true,
"experimentalDecorators": true,
"module": "esnext",
"moduleResolution": "node",
"baseUrl": ".",
"types": [
"quasar"
]
},
"exclude": ["node_modules"]
}
/<code>
graphql 支持
1、 創建 .graphqlconfig 文件
<code>{
"name": "Untitled GraphQL Schema",
"schemaPath": "schema.gql",
"extensions": {
"endpoints": {
"Default GraphQL Endpoint": {
"url": "http://localhost:8080/v1/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": false
}
}
}
}
/<code>
2、添加 graphql 支持
<code>yaran add graphql apollo-client apollo-link-http applo-link-context graphql-tag
/<code>
封裝 apolloClient
quasar 這個框架有 graphql 模塊,但對 typescript 支持不太好,所以我自己封裝了下,並增加了依賴注入的支持,採用 inversify 這個 lib先添加支持
<code>yarn add inversify inversify-props
/<code>
創建 apolloClient.service.interface.ts
<code>import ApolloClient from 'apollo-client'
export default interface ApolloClientServiceInterface {
client: ApolloClient/<code>
}
創建 apolloClient.service.ts
<code>import fetch from 'node-fetch'
import { ApolloClient } from 'apollo-client'
import { InMemoryCache } from 'apollo-cache-inmemory'
import { createHttpLink } from 'apollo-link-http'
import { ApolloLink } from 'apollo-link'
import { onError } from 'apollo-link-error'
import { injectable } from 'inversify-props'
import { uid } from 'quasar'
import ApolloClientServiceInterface from './apolloClient.service.interface'
@injectable()
class ApolloClientService implements ApolloClientServiceInterface {
public client: ApolloClient/<code>
public readonly uid: string = uid()
public constructor (
) {
const httpLink = createHttpLink({
uri: 'http://localhost:8080/v1/graphql',
fetch: fetch as any
})
const logoutLink = onError((error) => {
const errorRes = error.response
if (errorRes) {
const errors = errorRes.errors
if (errors && errors.length) {
const error = errors[0]
}
}
})
const apolloClient = new ApolloClient({
link: ApolloLink.from([
logoutLink,
httpLink
]),
cache: new InMemoryCache(),
connectToDevTools: true,
defaultOptions: {
query: {
fetchPolicy: 'network-only',
errorPolicy: 'all'
},
mutate: {
errorPolicy: 'all'
}
}
})
this.client = apolloClient
}
}
export default ApolloClientService
以上兩個文件是做為 apollo 服務的客戶端的一個簡單封裝,然後再增加一個基礎服務類,用於 CRUD
創建 baseApolloCrud.service.ts
<code>import BaseCrudServiceInterface from '../baseCrud.service.interface'
import ApolloClientService from './apolloClient.service'
import { injectSingleton } from '../../diContainer'
import { injectable } from 'inversify-props'
import {PaginatedList} from '../../../interfaces/page.interface';
@injectable()
export default abstract class BaseApolloCrudServiceimplements BaseCrudServiceInterface /<code>{
/**
* Our Apollo Client instance.
* Note: Will (and should) be a singleton.
*/
@injectSingleton(ApolloClientService)
public readonly apolloClientService!: ApolloClientService
abstract create (dto: DTO): Promise
abstract delete (id: string): Promise
abstract get (params?: any): Promise<paginatedlist>>
abstract getById (id: string): Promise/<paginatedlist>
abstract update (dto: DTO): Promise
}
這個類用到了反射機制,注入了 apolloClientService 實例,供子服務直接調用查詢,熟悉面嚮對象語言的朋友應該比較熟悉這種感覺,但 ts 更加靈活些。
創建 baseCrud.service.interface.ts 進一步解耦
<code>import {PaginatedList} from '../../interfaces/page.interface';
export default interface BaseCrudServiceInterface{ /<code>
get (params?: any): Promise<paginatedlist>> // TODO: Type this PaginatedList
getById (id: string): Promise/<paginatedlist>
create (dto: DTO): Promise
update (dto: DTO): Promise
delete (id: number): Promise
}
創建 baseCrud.service.ts 接口實現類
<code>import { injectable } from 'inversify-props'
import BaseApolloCrudService from './apollo/baseApolloCrud.service'
@injectable()
export default abstract class BaseCrudServiceextends BaseApolloCrudService /<code>{
}
這個類繼承 BaseApolloCrudService 並且是個抽象類,做為服務基類進行擴展
然後創建 diContainer.ts
<code>import 'reflect-metadata'
import { container } from 'inversify-props'
import { Cookies, QSsrContext } from 'quasar'
import { Store } from 'vuex'
import { RootState } from '../store/types'
import ApolloClientService from './_base/apollo/apolloClient.service';
import PostService from './posts/post.service';
import PostServiceInterface from './posts/post.service.interface';
import PostCurdDto from './posts/dto/postCurd.dto';
import Post from './posts/post.model';
import StoreService from "./_base/store.service";
export const buildDependencyContainer = (ssrContext: QSsrContext, store: Store<rootstate>) => {
// console.log('Binding dependencies: ', ssrContext, store)
container.unbindAll()
const cookies = process.env.SERVER
? Cookies.parseSSR(ssrContext)
: Cookies
// Singletons
container.bind<apolloclientservice>(ApolloClientService).toSelf().inSingletonScope()
container.bind<storeservice>(StoreService).toSelf().inSingletonScope()
// Transient (instance per)
container.addTransient<postserviceinterface>>(PostService)
return container
}
export { container }
export function injectSingleton (type: any): any {
return function (target: any, targetKey: string, index?: number): any {
Reflect.deleteProperty(target, targetKey)
Reflect.defineProperty(target, targetKey, {
get () {
return container.get(type)
},
set (value) {
return value
}
})
}
}
/<postserviceinterface>/<storeservice>/<apolloclientservice>/<rootstate>/<code>
這個是做為全局依賴注入的容器類,把它引入到 store/index.ts 中
<code>import Vue from 'vue'
import Vuex, { Store } from 'vuex'
import { buildDependencyContainer } from '../modules/diContainer'
import { QSsrContext } from 'quasar'
import {RootState} from "./types";
import {ui} from "./modules/ui";
Vue.use(Vuex)
let store: Store<rootstate>
export default function ({ ssrContext }: { ssrContext: QSsrContext }) {
store = new Vuex.Store( {
modules: {
// book,
// auth,
ui,
// user
},
// enable strict mode (adds overhead!)
// for dev mode only
strict: !!process.env.DEV
})
buildDependencyContainer(ssrContext, store)
return store
}
export { store }
/<rootstate>/<code>
這樣在應用啟動時會執行容器內部的依賴實例化操作,達到控制反轉的目的。
這樣基礎的配製工作都已完成,看配置過程可能稍顯複雜,但是事實上沒有過多的邏輯,當這些配置完成後,我們的系統已經完成了大半,下一步就是具體的前端實現,因為前端過於簡單,可參考文尾的源碼。
總結:
這篇文章中主要是實踐了Hasura GraphQL Engine 服務,因為有了這種實時數據服務的存在,我們可以省去大量的服務端開發,可以輕鬆定製各種垮平臺的業務。而客戶端雖然 quasar 非常好,但事實上不是僅有它才可以做到,還有很多的解決方案,比如普通的 vuejs 程序、react、gatsbyjs、gridsome 等等。
後面我還會持續完善這個小產品,讓它成為一個非常強大的資源產品。
源碼與訪問地址:
https://github.com/baisheng/picker-client
https://design.picker.cc
也可以直接點我個人頁面底部的導航查看效果。
閱讀更多 劉佰晟 的文章