我尝试用PostGraphile替换了附带的GraphQL后端,并且还有Vue-Apollo附带的演示聊天应用程序” ApolloChat”

由于当前情况下代码解释不足,我会在之后增补说明。

這篇文章中要做的事情

将附带的 “ApolloChat” GraphQL 服务器,使用 PostGraphile 实现并替换,以将数据持久化到 PostgreSQL 中。以后我们将把附带的 “ApolloChat” 称为 “原始 ApolloChat”,将替换为 PostGraphile 的 “ApolloChat” 称为 “PostGraphile 版 ApolloChat”。

PostGraphile版ApolloChat的源代码

您可以从https://github.com/kanedaq/vue-apollo 获取该项目。要运行它,请执行以下步骤以启动 Web 服务器,并从浏览器访问“http://localhost:8080/demo/”。

git clone https://github.com/kanedaq/vue-apollo
cd vue-apollo/tests/demo/
yarn install
yarn serve

「实施中的懒惰(常见于演示中)」

    • httpsで通信すべきところもhttpで通信しています。

 

    • 本記事ではJWTをローカルストレージに保存していますが、本番環境ではなさらないようお願いします。HTML5のLocal Storageを使ってはいけない(翻訳) (2019-10-10追記)

 

    • ログアウト処理はサーバー側は何もせずにtrueを返すだけにし、フロント側でブラウザのローカルストレージのJWTを削除するに留めました。きっちり実装したい場合は、以下のサイト等をご参照ください。

JSON Web Token を失効させる
SPAでログアウトした時にすべき処理って何ですか?

ログイン中に”jwt expired”のために動作が怪しくなったら、ログアウトボタンを押して強制的にJWTを削除してください。
ログイン画面で”jwt expired”のためにログインできなくなったら、ブラウザのローカルストレージに残っているJWT(キー:postgraphile-demo-token)を手動で削除してください。
その他、PostGraphileへの移植に際して、実装の抜け漏れがあるかと思います。
私(C++おじさん)はまだJavaScriptもVue.jsも経験が浅いので、変な箇所がありましたら優しくご指摘ください。

请参考该页面(感谢您)。

GraphiQL无法指定标头的解决方法
Vue:无效的主机标头
注意事项:在子目录中分发Vue.js项目
使用Vue CLI3的DevServer使用WebSocket
尝试在nginx(1.3.13)中使用WebSocket代理
使用Node.js和log4js输出日志
util.inspect的便利性

获取vue-apollo,并尝试运行原始的ApolloChat。

在将GraphQL后端转移到PostGraphile之前,首先尝试运行原始的ApolloChat。我将在~/work目录下进行操作(我使用的是Mac)。

获取 vue-apollo。

cd ~/work
git clone https://github.com/Akryum/vue-apollo
cd vue-apollo

在这里运行”gut log”命令会显示如下内容。

$ git log
commit 9419ffd03d5c0942ac5954572920ffa2281f1851 (HEAD -> master, origin/master, origin/HEAD)
Author: Darryl Hein <dhein@xmmedia.com>
Date:   Tue May 28 11:38:41 2019 -0600

    docs: Minor spelling correction (#635)
(後略)

在原始ApolloChat的目录中切换,并安装所需的软件包。

cd ~/work/vue-apollo/tests/demo
yarn install

我将立即试运行原版ApolloChat。要查找启动方法,请进行检索。

more package.json

显示如下。

{
  "name": "demo",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "apollo:dev": "vue-cli-service apollo:watch",
    "apollo:run": "vue-cli-service apollo:run",
    "test:e2e:dev": "start-server-and-test apollo:dev http://localhost:4000/.well-known/apollo/server-health test:e2e:dev:client",
    "test:e2e:dev:client": "vue-cli-service test:e2e --mode development",
    "test:e2e": "start-server-and-test apollo:run http://localhost:4000/.well-known/apollo/server-health test:e2e:client",
    "test:e2e:client": "vue-cli-service test:e2e --mode production --headless",
    "test": "yarn run lint --no-fix && yarn run test:e2e"
  },

在中文中,原始的ApolloChat启动命令是:
– GraphQL服务器: yarn apollo:dev或yarn apollo:run
– Web服务器(Vue.js应用程序发布): yarn serve
看起来不错。

首先,执行“yarn apollo:dev”以启动GraphQL服务器,将显示如下内容。

warning ../../../package.json: No license field
$ vue-cli-service apollo:watch
warning ../../../package.json: No license field
$ vue-cli-service apollo:run --delay
Using default PubSub implementation for subscriptions.
You should provide a different implementation in production (for example with Redis) by exporting it in 'apollo-server/pubsub.js'.
✔️  GraphQL Server is running on http://localhost:4000/graphql
✔️  Type rs to restart the server

按照指示,在浏览器中访问“http://localhost:4000/graphql”,会出现类似官方的GraphiQL的界面。本文将省略不表,但您可以尝试在“~/work/vue-apollo/tests/demo/src/graphql/”目录下的gql文件中编写的GraphQL查询。您可以在界面的左下方输入“HTTP HEADERS”,这样您就可以将登录过程中返回的JWT设置为HEADERS,以便尝试需要登录的查询。

在保持GraphQL服务器的同时,可以在另一个控制台上启动Web服务器。

cd ~/work/vue-apollo/tests/demo
yarn serve

编译完前端后,显示如下。

 DONE  Compiled successfully in 7780ms                                  17:51:01


  App running at:
  - Local:   http://localhost:8080/ 
  - Network: unavailable

  Note that the development build is not optimized.
  To create a production build, run yarn build.

按照指示,在浏览器中访问“http://localhost:8080/”,原始ApolloChat将启动。
首先会出现登录页面。

スクリーンショット 2019-06-13 9.55.48.png

创建账号页面。

スクリーンショット 2019-06-13 9.56.01.png

创立账号并登录后,出现的欢迎界面。

スクリーンショット 2019-06-13 9.56.23.png

进入通用频道,打算发送“你好,世界”的界面。

スクリーンショット 2019-06-13 9.58.08.png

发送了「你好,世界」后的屏幕。

スクリーンショット 2019-06-13 9.58.16.png

尝试了各种操作后,发现原版的ApolloChat的GraphQL服务器将数据保存在内存中,而不进行持久化,这意味着一旦关闭GraphQL服务器,所创建的账户和聊天内容将会丢失。

后端实现

那么,让我们开始将原始的ApolloChat的GraphQL服务器迁移到PostGraphile。数据将持久化存储在PostgreSQL中。

环境搭建和后端实现(第一部分):涉及到PostgreSQL的相关事项。

由于之前已经写过一篇名为“尝试使用PostgREST和PostGraphile来提供PostgreSQL操作API”的文章中的“环境设置1”部分与此完全相同,所以本文将略去该部分。

在这个环境设置中,Laradock的workspace中的“/var/www”将被挂载到主机的“~/work”目录上。

创建数据库:环境搭建和后端实施(第二部分)

ER图

我在运行原始的ApolloChat,并查看~/work/vue-apollo/tests/demo/apollo-server/schema.graphql,尝试绘制ER图表。

er.png

在这4个表中,我们决定将以下3个表存储在PostgreSQL的apollo_demo模式中,以供登录用户查看。

    • channels

 

    • messages

 

    users

决定将用户不想展示的以下表格存储在PostgreSQL的apollo_demo_private模式中。

    user_accounts

PostgreSQL的功能之一,模式(schema)类似于命名空间,可以用于将想要显示的对象和不想显示的对象分开,以用于安全目的。

实施工作

为避免混乱,我们将删除原始的GraphQL服务器,并创建一个新的后端文件存储位置来进行开发。

cd ~/work/vue-apollo/tests/demo/
rm -rf apollo-server
mkdir postgraphile-server

我进入Laradock的工作区。

cd ~/work/laradock
docker-compose exec --user=laradock workspace bash

进一步进入psql。默认用户密码为:secret。

psql -h postgres -U default

请创建一个可以连接的PostGraphile版ApolloChat数据库,并确认其可连接性。
你可以将数据库名设置为”demodb”,但名称可以任意取。

create database demodb;
\connect demodb

如果忘记连接到「demodb」数据库,注意会在另一个数据库中创建模式。

请继续执行以下SQL语句,创建模式和数据(稍后会解释内容)。
该SQL语句将保存在“~/work/vue-apollo/tests/demo/postgraphile-server/schema_and_data.sql”中。
另外,JWT的到期时间设置为60分钟。如果需要更改,请修改“60 minutes”这一部分。

begin;

create role apollo_demo_postgraphile login password 'xyz';

create role apollo_demo_anonymous;
grant apollo_demo_anonymous to apollo_demo_postgraphile;

create role apollo_demo_login_user;
grant apollo_demo_login_user to apollo_demo_postgraphile;

create schema apollo_demo;
create schema apollo_demo_private;

create table apollo_demo.users (
  id               integer primary key generated by default as identity,
  nickname         text not null,
  email            text not null unique check (email ~* '^.+@.+\..+$')
);

comment on table apollo_demo.users is 'A user of the chat.';
comment on column apollo_demo.users.id is 'The primary unique identifier for the user.';
comment on column apollo_demo.users.nickname is 'The user’s nickname.';
comment on column apollo_demo.users.email is 'The email address of the user.';

create table apollo_demo.channels (
  id               text primary key,
  name             text not null
);

comment on table apollo_demo.channels is 'A channel of the chat.';
comment on column apollo_demo.channels.id is 'The primary key for the channel.';
comment on column apollo_demo.channels.name is 'The channel’s name.';

create table apollo_demo.messages (
  id               integer primary key generated by default as identity,
  channel_id       text not null references apollo_demo.channels(id),
  user_id          integer not null references apollo_demo.users(id),
  content          text not null,
  date_added       timestamp not null default current_timestamp,
  date_updated     timestamp
) ;

comment on table apollo_demo.messages is 'A message written by a user.';
comment on column apollo_demo.messages.id is 'The primary key for the message.'; 
comment on column apollo_demo.messages.channel_id is 'The id of the channel.';
comment on column apollo_demo.messages.user_id is 'The id of the user.';
comment on column apollo_demo.messages.content is 'The content this has been posted in.';
comment on column apollo_demo.messages.date_added is 'The time this message was added.';
comment on column apollo_demo.messages.date_updated is 'The time this message was updated';

alter default privileges revoke execute on functions from public;

create function apollo_demo_private.set_date_updated() returns trigger as $$
begin
  new.date_updated := current_timestamp;
  return new;
end;
$$ language plpgsql;

create trigger messages_date_updated before update
  on apollo_demo.messages
  for each row
  execute procedure apollo_demo_private.set_date_updated();

create table apollo_demo_private.user_accounts (
  user_id          integer primary key references apollo_demo.users(id) on delete cascade,
  password_hash    text not null
) ;

comment on table apollo_demo_private.user_accounts is 'Private information about a user’s account.';
comment on column apollo_demo_private.user_accounts.user_id is 'The id of the User associated with this account.';
comment on column apollo_demo_private.user_accounts.password_hash is 'An opaque hash of the user password.';

create extension if not exists "pgcrypto";

create function apollo_demo.user_register(
  nickname text,
  email text,
  password text
) returns apollo_demo.users as $$
declare
  usr apollo_demo.users;
begin
  insert into apollo_demo.users (nickname, email) values
    (user_register.nickname, user_register.email)
    returning * into usr;

  insert into apollo_demo_private.user_accounts (user_id, password_hash) values
    (usr.id, crypt(user_register.password, gen_salt('bf')));

  return usr;
end;
$$ language plpgsql strict security definer;

comment on function apollo_demo.user_register(text, text, text) is 'Registers a single user and creates an account in our chat.';

create type apollo_demo.jwt_token as (
  role text,
  user_id integer,
  exp integer
);

create type apollo_demo.usr_and_token as (
  usr apollo_demo.users,
  token apollo_demo.jwt_token
);

create function apollo_demo.user_login(
  email text,
  password text
) returns apollo_demo.usr_and_token as $$
  select ((users.*)::apollo_demo.users, ('apollo_demo_login_user', users.id, extract(epoch from (now() + interval '60 minutes')))::apollo_demo.jwt_token)::apollo_demo.usr_and_token
    from apollo_demo.users
      inner join apollo_demo_private.user_accounts
      on users.id = user_accounts.user_id
    where 
      users.email = user_login.email
      and user_accounts.password_hash = crypt(user_login.password, user_accounts.password_hash);
$$ language sql strict security definer;

comment on function apollo_demo.user_login(text, text) is 'Creates a JWT token that will securely identify a user and give them certain permissions.';

create function apollo_demo.user_current() returns apollo_demo.users as $$
  select *
  from apollo_demo.users
  where id = current_setting('jwt.claims.user_id', true)::integer
$$ language sql stable;

comment on function apollo_demo.user_current() is 'Gets the user who was identified by our JWT.';

create function apollo_demo.change_password(
  current_password text,
  new_password text
) 
returns boolean as $$
declare
  usr_current apollo_demo.users;
begin
  usr_current := apollo_demo.user_current();
  if exists (select 1 from apollo_demo_private.user_accounts where user_accounts.user_id = usr_current.id and user_accounts.password_hash = crypt(change_password.current_password, usr_current.password_hash)) 
  then
    update apollo_demo_private.user_accounts set password_hash = crypt(change_password.new_password, gen_salt('bf'))
      where user_accounts.user_id = usr_current.id; 
    return true;
  else 
    return false;
  end if;
end;
$$ language plpgsql strict security definer;

create function apollo_demo.user_logout(
) returns boolean as $$
begin
  return true;
end;
$$ language plpgsql strict security definer;

create function apollo_demo.message_add(
  channel_id text,
  content text
) returns apollo_demo.messages as $$
declare
  message apollo_demo.messages;
begin
  INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
    (message_add.channel_id, current_setting('jwt.claims.user_id', true)::integer, message_add.content)
    RETURNING messages.* into message;
  return message;
end;
$$ language plpgsql strict security definer;

CREATE SEQUENCE mock_message_seq
    INCREMENT BY 1
    START WITH 1
;

create function apollo_demo.mock_message_send(
) returns boolean as $$
begin
  INSERT INTO apollo_demo.messages (channel_id, user_id, content)
    SELECT 'general', 0, 'How are you doing? ' || nextval('mock_message_seq');
  return true;
end;
$$ language plpgsql strict security definer;

grant usage on schema apollo_demo to apollo_demo_anonymous, apollo_demo_login_user;

grant select on table apollo_demo.users to apollo_demo_login_user;
grant update, delete on table apollo_demo.users to apollo_demo_login_user;

grant select on table apollo_demo.channels to apollo_demo_login_user;
grant insert, update, delete on table apollo_demo.channels to apollo_demo_login_user;

grant select on table apollo_demo.messages to apollo_demo_login_user;
grant insert, update, delete on table apollo_demo.messages to apollo_demo_login_user;

grant execute on function apollo_demo.user_register(text, text, text) to apollo_demo_anonymous;
grant execute on function apollo_demo.user_login(text, text) to apollo_demo_anonymous;
grant execute on function apollo_demo.user_current() to apollo_demo_login_user;
grant execute on function apollo_demo.change_password(text, text) to apollo_demo_login_user;
grant execute on function apollo_demo.user_logout() to apollo_demo_anonymous, apollo_demo_login_user;
grant execute on function apollo_demo.message_add(text, text) to apollo_demo_login_user;
grant execute on function apollo_demo.mock_message_send() to apollo_demo_login_user;

alter table apollo_demo.users enable row level security;
alter table apollo_demo.messages enable row level security;

create policy select_users on apollo_demo.users for select
  using (true);

create policy select_messages on apollo_demo.messages for select
  using (true);

create policy update_users on apollo_demo.users for update to apollo_demo_login_user
  using (id = current_setting('jwt.claims.user_id', true)::integer);

create policy delete_users on apollo_demo.users for delete to apollo_demo_login_user
  using (id = current_setting('jwt.claims.user_id', true)::integer);

create policy insert_messages on apollo_demo.messages for insert to apollo_demo_login_user
  with check (user_id = current_setting('jwt.claims.user_id', true)::integer);

create policy update_messages on apollo_demo.messages for update to apollo_demo_login_user
  using (user_id = current_setting('jwt.claims.user_id', true)::integer);

create policy delete_messages on apollo_demo.messages for delete to apollo_demo_login_user
  using (user_id = current_setting('jwt.claims.user_id', true)::integer);


INSERT INTO apollo_demo.users (id, nickname, email) VALUES
  (0, 'The Bot', 'bot@bot.com');

INSERT INTO apollo_demo_private.user_accounts (user_id, password_hash) VALUES
  (0, crypt('bot', gen_salt('bf')));

INSERT INTO apollo_demo.channels (id, name) VALUES
  ('general', 'General discussion'),
  ('random', 'Have fun chatting!'),
  ('help', 'Ask for or give help');

INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
  ('general', 0, 'Welcome to the chat!');


create type apollo_demo.message_nullable as (
  id               integer,
  channel_id       text,
  user_id          integer,
  content          text,
  date_added       timestamp,
  date_updated     timestamp
) ;

create or replace function apollo_demo.graphql_subscription() returns trigger as $$
declare
  v_process_new bool = (TG_OP = 'INSERT' OR TG_OP = 'UPDATE');
  v_process_old bool = (TG_OP = 'UPDATE' OR TG_OP = 'DELETE');
  v_event text = TG_ARGV[0];
  v_topic_template text = TG_ARGV[1];
  v_attribute text = TG_ARGV[2];
  v_record record;
  v_sub text;
  v_topic text;
begin
  if v_process_new then
    v_record = new;
  else
    v_record = old;
  end if;

  if v_attribute is not null then
    execute 'select $1.' || quote_ident(v_attribute)
    using v_record
    into v_sub;
  end if;

  if v_sub is not null then
    v_topic = replace(v_topic_template, '$1', v_sub);
  else
    v_topic = v_topic_template;
  end if;

  perform pg_notify(v_topic, json_build_object(
      'event', v_event,
      'newrec', new.*,
      'oldrec', old.*,
      'type', TG_OP
    )::text);

  -- RAISE LOG     'v_topic = %', v_topic;
  -- RAISE LOG     'json_build_object = %', json_build_object('event', v_event, 'newrec', new.*, 'oldrec', old.*, 'type', TG_OP);

  return v_record;
end;
$$ language plpgsql volatile set search_path from current;

DROP TRIGGER IF EXISTS _500_gql_update_message ON apollo_demo.messages;
CREATE TRIGGER _500_gql_update_message
  AFTER INSERT OR UPDATE OR DELETE ON apollo_demo.messages
  FOR EACH ROW
  EXECUTE PROCEDURE apollo_demo.graphql_subscription(
    'messageChanged', -- the "event" string, useful for the client to know what happened
    'graphql:message:$1', -- the "topic" the event will be published to, as a template
    'channel_id' -- If specified, `$1` above will be replaced with NEW.channel_id or OLD.channel_id from the trigger.
  );

commit;

如果处理成功,我会退出psql和Laradock的workspace,然后回到主机上。

环境搭建和后端实现(第三部分):与PostGraphile相关。

我們決定使用PostGraphile官方Docker映像。然而,為了運行GraphQL訂閱,需要在映像中安裝JavaScript包。因此,我們將以官方映像為基礎映像創建新的Docker映像。官方映像的Dockerfile在這裡。

创建一个存放新的Dockerfile的位置。

mkdir -p ~/work/laradock/postgraphile_plus

创建一个新的Dockerfile。

FROM graphile/postgraphile

ENV NODE_PATH=/usr/local/lib/node_modules

# RUN yarn global add @graphile/pg-pubsub graphile-build graphql postgraphile graphile-utils log4js
RUN npm install -g @graphile/pg-pubsub graphile-build graphql postgraphile graphile-utils log4js

ENTRYPOINT ["./cli.js"]

在验证中使用JWT。
执行以下操作来生成JWT的共享密钥(私钥)(Linux命令)。该共享密钥将在令牌创建和验证过程中通用使用。

LC_CTYPE=C < /dev/urandom tr -dc A-Za-z0-9 | head -c32

在本页面中,我们使用以下共同密钥。由于它是个秘密钥匙,所以在正式环境中需要特别小心处理。

K3INqoxbNWCB4hYiDnX2zlbIpm8UA5zR

在Laradock的docker-compose.yml文件的末尾添加以下内容。还配置了JWT使用的共享密钥。

### PostGraphile ################################################
    postgraphile_demo:
      build:
        context: ./postgraphile_plus
      environment:
        DATABASE_URL: "postgres://apollo_demo_postgraphile:xyz@postgres:${POSTGRES_PORT}/demodb"
      expose:
        - "16000"
      command: ["--plugins", "@graphile/pg-pubsub", "--append-plugins", "/root/www/vue-apollo/tests/demo/postgraphile-server/DemoSubscriptionPlugin.js", "--subscriptions", "--connection", "postgres://apollo_demo_postgraphile:xyz@postgres:${POSTGRES_PORT}/demodb", "--port", "16000", "--schema", "apollo_demo", "--default-role", "apollo_demo_anonymous", "--secret", "K3INqoxbNWCB4hYiDnX2zlbIpm8UA5zR", "--token", "apollo_demo.jwt_token", "--export-schema-graphql", "/root/www/vue-apollo/tests/demo/postgraphile-server/schema.graphql"]
      ports:
        - "16000:16000"
      depends_on:
        - postgres
      restart: on-failure:20
      networks:
        - backend
      volumes:
        - ${APP_CODE_PATH_HOST}:/root/www

由于上述的docker-compose.yml文件中,已经配置了使用”DemoSubscriptionPlugin.js”作为PostGraphile的插件,因此也需要创建该文件。插件的详细内容稍后将进行介绍。

请按照以下内容用DemoSubscriptionPlugin.js进行新建:
文件的路径为「~/work/vue-apollo/tests/demo/postgraphile-server」。

const DEBUG_MODE = false

////////////////////////////////////////////////////////////////
// デバッグ用コード。デバッグ時に /root/www/debug.log にログを出力する。
////////////////////////////////////////////////////////////////
const log4js = require('log4js')
const util = require('util')

log4js.configure({
  appenders : {
    debug : {type : 'file', filename : '/root/www/debug.log'}
  },
  categories : {
    default : {appenders : ['debug'], level : 'debug'},
  }
});
var logger = log4js.getLogger('debug');

////////////////////////////////////////////////////////////////
// 以下、https://www.graphile.org/postgraphile/subscriptions/ の
// MySubscriptionPlugin.jsを改変
////////////////////////////////////////////////////////////////
const { makeExtendSchemaPlugin, gql, embed } = require("graphile-utils");

const makeTopic = (_args, context, _resolveInfo) => {
  if (DEBUG_MODE) {
    logger.debug('makeTopic() in');
    logger.debug('_args = ');
    logger.debug(util.inspect(_args));
    logger.debug('context = ');
    logger.debug(util.inspect(context));

    // デバッグ時に、JWTが渡されなくてもSubscriptionを有効にしたいなら、ここでreturn
    return `graphql:message:${_args.channelId}`;
  }

  if (context.jwtClaims.user_id) {
    return `graphql:message:${_args.channelId}`;
  } else {
    throw new Error("You're not logged in");
  }
};

module.exports = makeExtendSchemaPlugin(({ pgSql: sql }) => ({
  typeDefs: gql`
    type MessageChanged {
      # This is returned directly from the PostgreSQL subscription payload (JSON object)
      type: String!

      # This is populated by our resolver below
      message: Message

      # This is returned directly from the PostgreSQL subscription payload (JSON object)
      oldrec: MessageNullable
    }

    extend type Subscription {
      messageChanged (channelId: String!): MessageChanged! @pgSubscription(topic: ${embed(
        makeTopic
      )})
    }
  `,

  resolvers: {
    MessageChanged: {
      // This method finds the user from the database based on the event
      // published by PostgreSQL.
      //
      // In a future release, we hope to enable you to replace this entire
      // method with a small schema directive above, should you so desire. It's
      // mostly boilerplate.

      async message(
        event,
        _args,
        _context,
        {
          graphile: { selectGraphQLResultFromTable },
        }
      ) {

        if (DEBUG_MODE) {
          logger.debug('message() in');
          logger.debug('event = ');
          logger.debug(util.inspect(event));
        }

        if (event.type === 'DELETE') {
          return null;
        }

        const rows = await selectGraphQLResultFromTable(
          sql.fragment`apollo_demo.messages`,
          (tableAlias, sqlBuilder) => {

            if (DEBUG_MODE) {
              logger.debug('selectGraphQLResultFromTable() in');
            }

            sqlBuilder.where(
              sql.fragment`${sqlBuilder.getTableAlias()}.id = ${sql.value(
                event.newrec.id
              )}`
            );
          }
        );

        if (DEBUG_MODE) {
          logger.debug('rows = ');
          logger.debug(util.inspect(rows));
        }
        return rows[0];
      },

    },
  },
}));

建立Docker环境

cd ~/work/laradock
docker-compose up -d postgres pgadmin workspace postgraphile_demo

请稍等片刻。一旦完成,

docker-compose ps

我們看到以下的顯示,請確認狀態已經變成”Up”。

            Name                          Command              State                Ports            
-----------------------------------------------------------------------------------------------------
laradock_docker-in-docker_1    dockerd-entrypoint.sh           Up       2375/tcp                     
laradock_pgadmin_1             docker-entrypoint.sh pgadmin4   Up       0.0.0.0:5050->5050/tcp       
laradock_postgraphile_demo_1   ./cli.js --plugins @graphi      Up       0.0.0.0:16000->16000/tcp,    
                               ...                                      5000/tcp                     
laradock_postgres_1            docker-entrypoint.sh postgres   Up       0.0.0.0:5432->5432/tcp       
laradock_workspace_1           sudo bash -c [ -e /var/run      Up       0.0.0.0:2222->22/tcp,        
                               ...                                      0.0.0.0:3389->3389/tcp,      
                                                                        0.0.0.0:14000->4000/tcp,     
                                                                        0.0.0.0:18080->8080/tcp      

确认PostGraphile能够连接到PostgreSQL。

docker logs laradock_postgraphile_demo_1

只要没有显示错误信息,PostGraphile就已经成功启动了。

PostGraphile v4.4.1-alpha.4 server listening on port 16000 ?

  ‣ GraphQL API:         http://0.0.0.0:16000/graphql (subscriptions enabled)
  ‣ GraphiQL GUI/IDE:    http://0.0.0.0:16000/graphiql
  ‣ Postgres connection: postgres://apollo_demo_postgraphile:[SECRET]@postgres/demodb
  ‣ Postgres schema(s):  apollo_demo
  ‣ Documentation:       https://graphile.org/postgraphile/introduction/

* * *

在没有任何启动的情况下,使用docker-compose一次性启动所有容器时,可能会发生一些错误,因为PostGraphile试图在等待PostgreSQL启动之前启动。关于这个现象,我在“PostGraphile(GraphQL)、Vue.js、Apollo、VuetifyでCRUD機能を実装してみた”一文中进行了描述,请参考。

现在在刚才的docker-compose.yml文件中,已经设置了将GraphQL模式导出到”schema.graphql”。在启动PostGraphile时,它会自动读取PostgreSQL的模式信息并生成GraphQL模式,该内容将被导出。现在我们来检查文件是否存在。

$ ls ~/work/vue-apollo/tests/demo/postgraphile-server
DemoSubscriptionPlugin.js   schema_and_data.sql
schema.graphql

后端实现解释

以上完成了使用PostGraphile实现类似于原始ApolloChat的GraphQL服务器的工作。剩下的就是修改前端源代码以使用PostGraphile,这样PostGraphile版的ApolloChat就能够运行了。但在此之前,我将解释一下我们在之前的工作中所进行的后端实现。

(说明将在以后写出)

前端改动

由於後端的實現已經完成,所以接下來將著手處理前端的Vue.js應用程式。
原始的ApolloChat啟動URL是「http://localhost:8080/」,但為了避免混淆,我們決定將PostGraphile版本的ApolloChat設置為「http://localhost:8080/demo/」。

续写(2019-06-19)

在本文中,我們將使用Altair GraphQL Client進行GraphQL查詢,並且已經將query和mutation設置在HEADERS中以認識JWT,然而,subscription卻無法被認識。在文章發布後,我發現了Graphql Playground的存在,試了一下發現它能夠認識subscription並且使用JWT,所以使用它會更方便一些。(追加結束)

请用中文改写GraphQL查询,并使用Altair GraphQL客户端进行尝试。

原始的ApolloChat使用的GraphQL查询语句存储在以下目录中。

$ ls ~/work/vue-apollo/tests/demo/src/graphql
channel.gql     messageFragment.gql userLogin.gql
channelFragment.gql messageRemove.gql   userLogout.gql
channels.gql        messageUpdate.gql   userRegister.gql
messageAdd.gql      userCurrent.gql
messageChanged.gql  userFragment.gql

在试验过程中,我们会逐个将这些 gql 文件修改为适用于 PostGraphile 的版本。

请下载并安装Altair GraphQL Client以进行查询。
打开Altair GraphQL Client,将URL字段输入为”http://localhost:16000/graphql”。

以下是PostGraphile自动生成的文件“schema.graphql”,其中包含了GraphQL模式,可供您在创建GraphQL查询时参考。
下面是文件内容的修改前后。

碎片

将以下内容以中文进行同义转述:用户片段.gql

没有变更。
(Means: There are no changes.)

fragment user on User {
  id
  nickname
}

channelFragment.gql的个别变种

没有变更。

fragment channel on Channel {
  id
  name
}

messageFragment.gql的意思是将gql片段作为消息。

原创

#import "./userFragment.gql"

fragment message on Message {
  id
  content
  user {
    ...user
  }
  dateAdded
  dateUpdated
}

更改后

#import "./userFragment.gql"

fragment message on Message {
  id
  content
  userByUserId {
    ...user
  }
  dateAdded
  dateUpdated
}

无需登录即可提问

用户注册的文件.gql

功能:用户注册

独创的

mutation userRegister ($input: UserRegister!) {
  userRegister(input: $input)
}

更改后

mutation userRegister ($input: UserRegisterInput!) {
  userRegister(input: $input) {
    user {
      id
      nickname
      email
    }
  }
}

我們將以下內容設定為VARIABLES,然後在Altair GraphQL Client中進行測試。

{
  "input": {
    "nickname": "foo",
    "email": "foo@foo.com",
    "password": "p@ss"
  }
}
スクリーンショット 2019-06-18 16.21.29.png

反应

{
  "data": {
    "userRegister": {
      "user": {
        "id": 1,
        "nickname": "foo",
        "email": "foo@foo.com"
      }
    }
  }
}

用户退出登录.gql

功能:退出登录

原创

mutation userLogout {
  userLogout
}

更改后

mutation userLogout ($input: UserLogoutInput!) {
  userLogout (input: $input) {
    boolean
  }
}

在设置VARIABLES后,我们将在Altair GraphQL客户端中进行尝试。

{
  "input": {
  }
}
スクリーンショット 2019-06-18 16.24.26.png

回应

{
  "data": {
    "userLogout": {
      "boolean": true
    }
  }
}

用户登录.gql

功能:登录

原版

#import "./userFragment.gql"

mutation userLogin ($email: String!, $password: String!) {
  userLogin (email: $email, password: $password) {
    user {
      ...user
      email
    }
    token {
      id
      userId
      expiration
    }
  }
}

更改后

mutation userLogin ($input: UserLoginInput!) {
  userLogin (input: $input) {
    usrAndToken {
      usr {
        id
        nickname
        email
      }
      token
    }
  }
}

我将以下内容设置为变量,并尝试在Altair GraphQL Client中运行。

{
  "input": {
    "email": "foo@foo.com",
    "password": "p@ss"
  }
}
スクリーンショット 2019-06-18 16.27.16.png

回应

{
  "data": {
    "userLogin": {
      "usrAndToken": {
        "usr": {
          "id": 1,
          "nickname": "foo",
          "email": "foo@foo.com"
        },
        "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBvbGxvX2RlbW9fbG9naW5fdXNlciIsInVzZXJfaWQiOjEsImV4cCI6MTU2MDg0NjM3NiwiaWF0IjoxNTYwODQyNzc2LCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ.uE6rXeIWDuFaLaAqE56OisICQKzntl5e4L4znSryoXU"
      }
    }
  }
}

需要登录才能查询的问题

因为在先前的登录处理响应中返回了JWT令牌,所以我们将其设置在Altair GraphQL客户端的HEADERS中,格式如下。

Authorization
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYXBvbGxvX2RlbW9fbG9naW5fdXNlciIsInVzZXJfaWQiOjEsImV4cCI6MTU2MDg0NjM3NiwiaWF0IjoxNTYwODQyNzc2LCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ.uE6rXeIWDuFaLaAqE56OisICQKzntl5e4L4znSryoXU
スクリーンショット 2019-06-18 16.29.00.png

如果以后询问时收到响应”jwt expired”,请从Altair GraphQL客户端的HEADERS中删除JWT,然后重新执行userLogin mutation,并将返回的令牌重新设置到HEADERS中。

用户当前.gql

功能:获取已登录用户的信息

没有变更。

#import "./userFragment.gql"

query userCurrent {
  userCurrent {
    ...user
    email
  }
}

我将在Altair GraphQL客户端中尝试执行。我直接输入了所导入的片段。

スクリーンショット 2019-06-18 16.34.30.png

响应

{
  "data": {
    "userCurrent": {
      "id": 1,
      "nickname": "foo",
      "email": "foo@foo.com"
    }
  }
}

频道.gql

功能:获取所有频道

原版

#import "./channelFragment.gql"

query channels {
  channels {
    ...channel
  }
}

变更后

#import "./channelFragment.gql"

query channels {
  allChannels {
    nodes {
      ...channel
    }
  }
}

我将尝试使用Altair GraphQL Client。

スクリーンショット 2019-06-18 16.38.59.png

回应

{
  "data": {
    "allChannels": {
      "nodes": [
        {
          "id": "general",
          "name": "General discussion"
        },
        {
          "id": "help",
          "name": "Ask for or give help"
        },
        {
          "id": "random",
          "name": "Have fun chatting!"
        }
      ]
    }
  }
}

频道.gql

功能:通过id搜索频道,并获取该频道的所有消息。
能力:根据ID搜索频道,并获取该频道的所有消息。

原始的

#import "./channelFragment.gql"
#import "./messageFragment.gql"

query channel ($id: ID!) {
  channel (id: $id) {
    ...channel
    messages {
      ...message
    }
  }
}

更改后

#import "./channelFragment.gql"
#import "./messageFragment.gql"

query channel ($id: String!) {
  channelById (id: $id) {
    ...channel
    messagesByChannelId {
      nodes {
        ...message
      }
    }
  }
}

我将在 Altair GraphQL 客户端中设置 VARIABLES 并进行尝试。

{
  "id": "general"
}
スクリーンショット 2019-06-18 16.41.31.png

响应

{
  "data": {
    "channelById": {
      "id": "general",
      "name": "General discussion",
      "messagesByChannelId": {
        "nodes": [
          {
            "id": 1,
            "content": "Welcome to the chat!",
            "userByUserId": {
              "id": 0,
              "nickname": "The Bot"
            },
            "dateAdded": "2019-06-18T07:10:27.671412",
            "dateUpdated": null
          }
        ]
      }
    }
  }
}

消息添加.gql

功能:在指定的频道中添加消息

原创

#import "./messageFragment.gql"

mutation messageAdd ($input: MessageAdd!) {
  messageAdd (input: $input) {
    ...message
  }
}

更改之后 zhī

#import "./messageFragment.gql"

mutation messageAdd ($input: MessageAddInput!) {
  messageAdd (input: $input) {
    message {
      ...message
    }
  }
}

我将使用以下设置在Altair GraphQL客户端中尝试。

{
  "input": {
    "channelId": "general",
    "content": "ハローワールド"
  }
}
スクリーンショット 2019-06-18 16.44.20.png

回应

{
  "data": {
    "messageAdd": {
      "message": {
        "id": 2,
        "content": "ハローワールド",
        "userByUserId": {
          "id": 1,
          "nickname": "foo"
        },
        "dateAdded": "2019-06-18T07:44:02.534676",
        "dateUpdated": null
      }
    }
  }
}

只需提供一种中文原生选项:

在这里再次发出通道查询,可以确认已添加了“你好,世界”消息。

スクリーンショット 2019-06-18 16.45.42.png

更新信息.gql

功能:消息更改(在ApolloChat中未使用)

原文

#import "./messageFragment.gql"

mutation messageUpdate ($input: MessageUpdate!) {
  messageUpdate (input: $input) {
    ...message
  }
}

修改后

#import "./messageFragment.gql"

mutation messageUpdate ($input: UpdateMessageByIdInput!) {
  updateMessageById (input: $input) {
    message {
      ...message
    }
  }
}

我将对之前使用messageAdd mutation添加的消息进行修改。VARIABLES是指。

{
  "input": {
    "messagePatch": {
      "content": "こんにちは世界"
    },
    "id": 2
  }
}
スクリーンショット 2019-06-18 16.48.38.png

回应

{
  "data": {
    "updateMessageById": {
      "message": {
        "id": 2,
        "content": "こんにちは世界",
        "userByUserId": {
          "id": 1,
          "nickname": "foo"
        },
        "dateAdded": "2019-06-18T07:44:02.534676",
        "dateUpdated": "2019-06-18T07:48:16.794758"
      }
    }
  }
}

当您在此处发出通道查询时,您可以确认“Hello World”消息已更改为“你好世界”。

スクリーンショット 2019-06-18 16.50.14.png

删除信息.gql

功能:消息删除(ApolloChat未使用)

原创

#import "./messageFragment.gql"

mutation messageRemove ($id: ID!) {
  messageRemove (id: $id) {
    ...message
  }
}

更改后

#import "./messageFragment.gql"

mutation messageRemove ($input: DeleteMessageByIdInput!) {
  deleteMessageById (input: $input) {
    message {
      ...message
    }
  }
}

我将尝试删除在先前的messageUpdate变异中更改的消息。 VARIABLES 是…

{
  "input": {
    "id": 2
  }
}
スクリーンショット 2019-06-18 16.52.23.png

回应

{
  "data": {
    "deleteMessageById": {
      "message": {
        "id": 2,
        "content": "こんにちは世界",
        "userByUserId": {
          "id": 1,
          "nickname": "foo"
        },
        "dateAdded": "2019-06-18T07:44:02.534676",
        "dateUpdated": "2019-06-18T07:48:16.794758"
      }
    }
  }
}

在这里发出频道查询,可以确认已经删除了“你好世界”的消息。

スクリーンショット 2019-06-18 16.53.14.png

消息已更改.gql

功能:订阅服务。若有人在指定频道中添加、修改或删除消息,服务器将发送通知。

「翻译为汉语后的结果」

#import "./messageFragment.gql"

subscription messageChanged ($channelId: ID!) {
  messageChanged (channelId: $channelId) {
    type
    message {
      ...message
    }
  }
}

更改之后

#import "./messageFragment.gql"

fragment messageNullable on MessageNullable {
  id
  channelId
  userId
  content
  dateAdded
  dateUpdated
}
subscription messageChanged ($channelId: String!) {
  messageChanged (channelId: $channelId) {
    type
    message {
      ...message
    }
    oldrec {
      ...messageNullable
    }
  }
}
关于订阅试用的议题

在Altair GraphQL Client中,只需在HEADERS中设置JWT令牌,即可尝试登录后的查询和变更操作,但是,订阅操作却无法识别该令牌。
关于这个问题,可以参考以下推文:

我了解到了Altair GraphQL Client的存在。我试着立刻使用它与PostGraphile配合,将JWT令牌设置到头部并尝试使用,查询和变更操作都能识别令牌,但是订阅操作(WebSocket)却不能识别。是我的使用方法出了问题吗?不过这个工具还是很方便的。

嘿,对于混淆感到抱歉。在订阅中,由于使用了Websockets(不允许指定头信息https://t.co/m4iPzKn7bV),你无法指定头信息。当开始订阅进行身份验证时,无法将连接参数传递给订阅服务器。

Apollo的订阅认证的描述来自于https://t.co/QqE3fDtT7z— Sir Muel I ?? (@imolorhe) 2019年6月7日。

由于这种情况,我们决定在后端放宽安全性,以便即使不登录也可以尝试使用messageChanged订阅。我们将在PostGraphile插件「DemoSubscriptionPlugin.js」中将DEBUG_MODE设置为true。

- const DEBUG_MODE = false
+ const DEBUG_MODE = true
(後略)

为了启用更改后的DemoSubscriptionPlugin.js,需要重新启动PostGraphile容器。

cd ~/work/laradock
docker stop laradock_postgraphile_demo_1
docker-compose up -d postgres pgadmin workspace postgraphile_demo

现在可以等待接收`messageChanged`的消息了。
此外,为了让`messageChanged`能够在创建发送数据时从PostgreSQL中获取数据,我们会在暂时降低PostgreSQL的安全性。

进入Laradock的workspace,然后进入psql。

docker-compose exec --user=laradock workspace bash
psql -h postgres -U default demodb

匿名用户将被赋予每个表的select权限。

grant select on table apollo_demo.messages to apollo_demo_anonymous;
grant select on table apollo_demo.users to apollo_demo_anonymous;
grant select on table apollo_demo.channels to apollo_demo_anonymous;

现在可以接收到messageChanged。
由于仍然需要使用psql,请保持其运行状态。

那么,让我们将以下内容设置到VARIABLES中,并尝试在Altair GraphQL Client中执行。

{
  "channelId": "general"
}

等待接收信息的屏幕。

スクリーンショット 2019-06-18 17.01.36.png

在这个状态下,使用psql发出以下的INSERT语句。

INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
    ('general', 1, '美味しい!');
スクリーンショット 2019-06-18 17.03.57.png

回应

{
  "data": {
    "messageChanged": {
      "type": "INSERT",
      "message": {
        "id": 3,
        "content": "美味しい!",
        "userByUserId": {
          "id": 1,
          "nickname": "foo"
        },
        "dateAdded": "2019-06-18T08:02:26.293796",
        "dateUpdated": null
      },
      "oldrec": null
    }
  }
}

通过psql INSERT命令返回了一个数据响应。
对于这个数据,使用psql发出UPDATE语句。

UPDATE apollo_demo.messages SET
    content = '美味しい!'
    WHERE id = 3;
スクリーンショット 2019-06-18 17.08.37.png

回应

{
  "data": {
    "messageChanged": {
      "type": "UPDATE",
      "message": {
        "id": 3,
        "content": "美味しい!",
        "userByUserId": {
          "id": 1,
          "nickname": "foo"
        },
        "dateAdded": "2019-06-18T08:02:26.293796",
        "dateUpdated": "2019-06-18T08:07:13.937846"
      },
      "oldrec": {
        "id": 3,
        "channelId": null,
        "userId": null,
        "content": "美味しい!",
        "dateAdded": null,
        "dateUpdated": null
      }
    }
  }
}

更新后的数据在响应中返回了。
我对oldrec(UPDATE前的行)的多数列为空值感到不满。这次在PostgreSQL中定义模式时,列名采用了蛇形命名法。当PostGraphile自动生成GraphQL模式时,它会将蛇形命名法转换为驼峰命名法。似乎对于这些已经进行了转换的列,没有传递任何值,导致它们变成了空值。在AppolloChat中,只使用了oldrec.id,所以正确传递值的实现将成为以后的课题,我们将继续进行下去。

对返回的数据,使用psql执行DELETE语句。

DELETE FROM apollo_demo.messages
    WHERE id = 3;
スクリーンショット 2019-06-18 17.09.44.png

对于”レスポンス”这个词可以有以下翻译:

– 回应
– 响应
– 反应
– 应答
– 反馈
– 回应时间

{
  "data": {
    "messageChanged": {
      "type": "DELETE",
      "message": null,
      "oldrec": {
        "id": 3,
        "channelId": null,
        "userId": null,
        "content": "美味しい!",
        "dateAdded": null,
        "dateUpdated": null
      }
    }
  }
}

我也收到了关于删除的通知。

通过执行以下REVOKE语句,在完成messageChanged订阅的尝试后,恢复之前放宽的安全措施。

revoke select on table apollo_demo.messages from apollo_demo_anonymous;
revoke select on table apollo_demo.users from apollo_demo_anonymous;
revoke select on table apollo_demo.channels from apollo_demo_anonymous;

在这种状态下,如果从psql再次执行上述的INSERT语句,会收到以下错误,并且可以了解到PostgreSQL方面的安全已经恢复。

スクリーンショット 2019-06-18 17.14.12.png

回应

{
  "errors": [
    {
      "message": "permission denied for table messages",
      "locations": [
        {
          "line": 25,
          "column": 5
        }
      ],
      "path": [
        "messageChanged",
        "message"
      ]
    }
  ],
  "data": {
    "messageChanged": {
      "type": "INSERT",
      "message": null,
      "oldrec": null
    }
  }
}

我会退出psql和Laradock的workspace,然后返回到主机。
我会点击Altair GraphQL Client的停止按钮来停止messageChanged订阅。

接下来,我们将修复PostGraphile插件”DemoSubscriptionPlugin.js”的安全问题。
将DEBUG_MODE设置为false,如下所示。

- const DEBUG_MODE = true
+ const DEBUG_MODE = false
(後略)

为了使修改后的DemoSubscriptionPlugin.js生效,需要重新启动PostGraphile容器。

cd ~/work/laradock
docker stop laradock_postgraphile_demo_1
docker-compose up -d postgres pgadmin workspace postgraphile_demo

即使在这种情况下使用Altair GraphQL客户端尝试启动messageChanged,也会返回错误消息并无法等待接收,我们确认了安全性得到恢复。

スクリーンショット 2019-06-18 17.20.17.png

回复

{
  "errors": [
    {
      "message": "Cannot read property 'user_id' of null",
      "locations": [
        {
          "line": 23,
          "column": 3
        }
      ],
      "path": [
        "messageChanged"
      ]
    }
  ]
}

~/工作/vue-apollo/tests/demo/src/components/MockSendMessage.vue

除了现有的gql文件外,Vue组件的源代码中还有直接编写的查询,请在修改查询的同时进行尝试。

查询功能:在general通道发布消息“你好吗?+连续编号”。

原件

<script>
import gql from 'graphql-tag'

export default {
  created () {
    this.SEND = gql`
      mutation mockMessageSend {
        mockMessageSend
      }
    `
  },
}
</script>

<template>
  <ApolloMutation
    :mutation="SEND"
    class="mock-send-message"
  >
    <a
      slot-scope="{ mutate }"
      type="button"
      @click="mutate"
    >Send bot message</a>
  </ApolloMutation>
</template>

<style lang="stylus" scoped>
.mock-send-message
  margin-top 8px
  display flex
  align-items center
  justify-content center
</style>

改变后

<script>
import gql from 'graphql-tag'

export default {
  created () {
    this.SEND = gql`
      mutation mockMessageSend ($input: MockMessageSendInput!) {
        mockMessageSend (input: $input) {
          boolean
        }
      }
    `
  },
}
</script>

<template>
  <ApolloMutation
    :mutation="SEND"
    :variables="{
      input: {
      },
    }"
    class="mock-send-message"
  >
    <a
      slot-scope="{ mutate }"
      type="button"
      @click="mutate"
    >Send bot message</a>
  </ApolloMutation>
</template>

<style lang="stylus" scoped>
.mock-send-message
  margin-top 8px
  display flex
  align-items center
  justify-content center
</style>

我要从GraphQL查询中提取出文本,并在VARIABLES中设定以下内容,然后用Altair GraphQL Client进行尝试。

{
  "input": {
  }
}
スクリーンショット 2019-06-18 17.26.20.png

回应

{
  "data": {
    "mockMessageSend": {
      "boolean": true
    }
  }
}

我们将在后续启动PostGraphile版ApolloChat时确认是否在此次试行中向general频道投稿了“’你好吗?’ + 连续编号”。

通过以上的操作,已经完成了GraphQL查询语句的修改和尝试。

除了前端的GraphQL查询外,其他源代码进行改写

那么,除了查询文之外的更改方面,我们将按照以下方式创建和修改文件。

~/work/vue-apollo/tests/demo/vue.config.js发生了变化。

为了避免CORS错误,我在devServer的proxy部分设置了反向代理(同时支持WebSocket和http)。

module.exports = {
  pluginOptions: {
    graphqlMock: false,
    apolloEngine: false,
  },
+   devServer: {
+     host: '0.0.0.0',
+     disableHostCheck: true,
+     proxy: {
+       "^/postgraphile/demo/ws": {
+         target: "http://localhost:16000",
+         ws: true,
+         changeOrigin: true,
+         pathRewrite: {
+           "^/postgraphile/demo/ws": "/"
+         }
+       },
+       "^/postgraphile/demo": {
+         target: "http://localhost:16000",
+         ws: false,
+         pathRewrite: {
+           "^/postgraphile/demo": "/"
+         }
+       },
+     }
+   },
+   publicPath: '/demo',

  /* Without vue-cli-plugin-apollo 0.20.0+ */
  // chainWebpack: config => {
  //   config.module
  //     .rule('vue')
  //     .use('vue-loader')
  //       .loader('vue-loader')
  //       .tap(options => {
  //         options.transpileOptions = {
  //           transforms: {
  //             dangerousTaggedTemplateString: true,
  //           },
  //         }
  //         return options
  //       })
  // }
}

如果您希望在Laradock的nginx中设置反向代理而不是在vue.config.js中进行设置,可以在 ” ~/work/laradock/nginx/site/default.conf ” 中添加以下设置。在启动docker-compose时,将同时启动nginx容器,然后进入workspace容器并使用yarn serve启动Web服务器。然后,通过主机浏览器访问 ” http://localhost/demo/ ” 来启动PostGraphile版ApolloChat。

   location /demo/ {
       proxy_pass http://workspace:8080/demo/;
   }

   location /postgraphile/demo/ws/ {
       proxy_pass http://postgraphile_demo:16000/;
       proxy_http_version 1.1;
       proxy_set_header Upgrade $http_upgrade;
       proxy_set_header Connection "upgrade";
   }

   location /postgraphile/demo/ {
       proxy_pass http://postgraphile_demo:16000/;
   }

~/work/vue-apollo/tests/demo/.env (New) – 工作/视图-阿波罗/测试/演示/.env

内容:配置联接PostGraphile端点。

VUE_APP_GRAPHQL_HTTP=http://localhost:8080/postgraphile/demo/graphql
VUE_APP_GRAPHQL_WS=ws://localhost:8080/postgraphile/demo/ws/graphql

引用内容将从~/work/vue-apollo/tests/demo/src/vue-apollo.js引用。
供参考,如果要在Laradock的nginx中进行反向代理设置而不是在vue.config.js中设置,可以将.env文件设置如下。

VUE_APP_GRAPHQL_HTTP=http://localhost/postgraphile/demo/graphql
VUE_APP_GRAPHQL_WS=ws://localhost/postgraphile/demo/ws/graphql

~/work/vue-apollo/tests/demo/.graphqlconfig.yml(改变)

我会告诉ApolloChat自动生成的PostGraphile GraphQL模式文件的位置。

projects:
  app:
-     schemaPath: apollo-server/schema.graphql
+     schemaPath: postgraphile-server/schema.graphql
    includes:
      - '**/*.gql'
    extensions:
      endpoints:
        default: 'http://localhost:4000/graphql'

~/work/vue-apollo/tests/demo/src/router.js (改动)

为了在demo子目录中发布Vue.js项目,只需要添加一行代码。

import Vue from 'vue'
import Router from 'vue-router'
import UserLogin from './components/UserLogin.vue'
import WelcomeView from './components/WelcomeView.vue'
import ChannelView from './components/ChannelView.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
+   base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: WelcomeView,
    },
    {
      path: '/login',
      name: 'login',
      component: UserLogin,
    },
    {
      path: '/chan/:id',
      name: 'channel',
      component: ChannelView,
      props: true,
    },
  ],
})

~/work/vue-apollo/tests/demo/src/vue-apollo.js (Modified)

import Vue from 'vue'
import VueApollo from '../../../'
import { createApolloClient, restartWebsockets } from 'vue-cli-plugin-apollo/graphql-client'
+ import { InMemoryCache } from 'apollo-cache-inmemory'

// Install the vue plugin
Vue.use(VueApollo)

// Name of the localStorage item
- const AUTH_TOKEN = 'apollo-token'
+ const AUTH_TOKEN = 'postgraphile-demo-token'

// Config
const defaultOptions = {
  // You can use `https` for secure connection (recommended in production)
  httpEndpoint: process.env.VUE_APP_GRAPHQL_HTTP || 'http://localhost:4000/graphql',
  // You can use `wss` for secure connection (recommended in production)
  // Use `null` to disable subscriptions
  wsEndpoint: process.env.VUE_APP_GRAPHQL_WS || 'ws://localhost:4000/graphql',
  // LocalStorage token
  tokenName: AUTH_TOKEN,
  // Enable Automatic Query persisting with Apollo Engine
  persisting: false,
  // Use websockets for everything (no HTTP)
  // You need to pass a `wsEndpoint` for this to work
  websocketsOnly: false,
  // Is being rendered on the server?
  ssr: false,
  // Override default http link
  // link: myLink,
  // Override default cache
  // cache: myCache,
+   cache: new InMemoryCache(),
  // Additional ApolloClient options
  // apollo: { ... }
  getAuth: tokenName => {
    // get the authentication token from local storage if it exists
    const token = localStorage.getItem(tokenName)
    // return the headers to the context so httpLink can read them
-     return token || ''
+     return token ? ('Bearer ' + token) : ''
  },
}

// Call this in the Vue app file
export function createProvider (options = {}, { router }) {
  // Create apollo client
  const { apolloClient, wsClient } = createApolloClient({
    ...defaultOptions,
    ...options,
  })
  apolloClient.wsClient = wsClient

  // Create vue apollo provider
  const apolloProvider = new VueApollo({
    defaultClient: apolloClient,
    defaultOptions: {
      $query: {
        fetchPolicy: 'cache-and-network',
      },
    },
    errorHandler (error) {
      if (isUnauthorizedError(error)) {
        // Redirect to login page
        if (router.currentRoute.name !== 'login') {
          router.replace({
            name: 'login',
            params: {
              wantedRoute: router.currentRoute.fullPath,
            },
          })
        }
      } else {
        console.log('%cError', 'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;', error.message)
      }
    },
  })

  return apolloProvider
}

// Manually call this when user log in
export async function onLogin (apolloClient, token) {
-   localStorage.setItem(AUTH_TOKEN, JSON.stringify(token))
+   localStorage.setItem(AUTH_TOKEN, token)
  if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
  try {
    await apolloClient.resetStore()
  } catch (e) {
    if (!isUnauthorizedError(e)) {
      console.log('%cError on cache reset (login)', 'color: orange;', e.message)
    }
  }
}

// Manually call this when user log out
export async function onLogout (apolloClient) {
  localStorage.removeItem(AUTH_TOKEN)
  if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient)
  try {
    await apolloClient.resetStore()
  } catch (e) {
    if (!isUnauthorizedError(e)) {
      console.log('%cError on cache reset (logout)', 'color: orange;', e.message)
    }
  }
}

function isUnauthorizedError (error) {
-   const { graphQLErrors } = error
-   return (graphQLErrors && graphQLErrors.some(e => e.message === 'Unauthorized'))
+   if (error) {
+     if (error.message.indexOf('Unauthorized') >= 0 || error.message.indexOf('permission denied') >= 0 || error.message.indexOf('status code 401') >= 0) {
+       return true
+     }
+   }
+   return false
}

~/work/vue-apollo/tests/demo/src/components/UserLogin.vue (修改)

<script>
import UserCurrent from '../mixins/UserCurrent'
import USER_CURRENT from '../graphql/userCurrent.gql'
import { onLogin } from '../vue-apollo'

export default {
  name: 'UserLogin',

  mixins: [
    UserCurrent,
  ],

  data () {
    return {
      showRegister: false,
      email: '',
      password: '',
      nickname: '',
    }
  },

  watch: {
    // If already logged in redirect to other page
    userCurrent (value) {
      if (value) {
        this.redirect()
      }
    },
  },

  methods: {
    async onDone (result) {
      if (this.showRegister) {
        this.showRegister = false
      } else {
        if (!result.data.userLogin) return
        const apolloClient = this.$apollo.provider.defaultClient
        // Update token and reset cache
-         const { id, userId, expiration } = result.data.userLogin.token
-         await onLogin(apolloClient, { id, userId, expiration })
+         await onLogin(apolloClient, result.data.userLogin.usrAndToken.token)
        // Update cache
        apolloClient.writeQuery({
          query: USER_CURRENT,
          data: {
-             userCurrent: result.data.userLogin.user,
+             userCurrent: result.data.userLogin.usrAndToken.usr,
          },
        })
      }
    },

    redirect () {
      this.$router.replace(this.$route.params.wantedRoute || { name: 'home' })
    },
  },
}
</script>

<template>
  <div class="user-login">
    <div class="logo">
      <i class="material-icons icon">chat</i>
    </div>
    <div class="app-name">
      Apollo<b>Chat</b>
    </div>
    <ApolloMutation
      :mutation="showRegister
        ? require('../graphql/userRegister.gql')
        : require('../graphql/userLogin.gql')"
      :variables="showRegister
        ? {
          input: {
            email,
            password,
            nickname,
          },
        }
        : {
-           email,
-           password,
+           input: {
+             email,
+             password,
+           },
        }"
      class="wrapper"
      @done="onDone"
    >
      <form
        slot-scope="{ mutate, loading, gqlError: error }"
        :key="showRegister"
        class="form"
        @submit.prevent="mutate()"
      >
        <input
          v-model="email"
          class="form-input"
          type="email"
          name="email"
          placeholder="Email"
          required
        >
        <input
          v-model="password"
          class="form-input"
          type="password"
          name="password"
          placeholder="Password"
          required
        >
        <input
          v-if="showRegister"
          v-model="nickname"
          class="form-input"
          name="nickname"
          placeholder="Nickname"
          required
        >
        <div v-if="error" class="error">{{ error.message }}</div>
        <template v-if="!showRegister">
          <button
            type="submit"
            :disabled="loading"
            class="button"
            data-id="login"
          >Login</button>
          <div class="actions">
            <a
              data-id="create-account"
              @click="showRegister = true"
            >Create an account</a>
          </div>
        </template>
        <template v-else>
          <button
            type="submit"
            :disabled="loading"
            class="button"
            data-id="submit-new-account"
          >Create new account</button>
          <div class="actions">
            <a @click="showRegister = false">Go back</a>
          </div>
        </template>
      </form>
    </ApolloMutation>
  </div>
</template>

<style lang="stylus" scoped>
@import '~@/style/imports'

.user-login
  height 100%
  display flex
  flex-direction column
  align-items center
  justify-content center

.logo
  .icon
    font-size 80px
    color $color

.app-name
  font-size 42px
  font-weight lighter
  margin-bottom 32px

.wrapper
  flex auto 0 0

.form
  width 100vw
  max-width 300px

.form-input,
.button
  display block
  width 100%
  box-sizing border-box

.form-input
  margin-bottom 12px

.actions
  margin-top 12px
  text-align center
  font-size 12px
</style>

~/work/vue-apollo/tests/demo/src/components/UserCurrent.vue(被更改)

<script>
import UserCurrent from '../mixins/UserCurrent'
import { onLogout } from '../vue-apollo'
import USER_LOGOUT from '../graphql/userLogout.gql'

export default {
  name: 'MessageForm',

  mixins: [
    UserCurrent,
  ],

  methods: {
    async logout () {
+       const apolloClient = this.$apollo.provider.defaultClient
+       onLogout(apolloClient)
      await this.$apollo.mutate({
        mutation: USER_LOGOUT,
+         variables: {
+           input: {
+           },
+         },
      })
-       const apolloClient = this.$apollo.provider.defaultClient
-       onLogout(apolloClient)
    },
  },
}
</script>

<template>
  <div class="user-current">
    <template v-if="userCurrent">
      <i class="material-icons user-icon">account_circle</i>
      <div class="info">
        <div class="nickname">{{ userCurrent.nickname }}</div>
        <div class="email">{{ userCurrent.email }}</div>
      </div>
      <button
        class="icon-button"
        data-id="logout"
        @click="logout()"
      >
        <i class="material-icons">power_settings_new</i>
      </button>
    </template>
  </div>
</template>

<style lang="stylus" scoped>
@import '~@/style/imports'

.user-current
  color white
  display grid
  grid-template-columns auto 1fr auto
  grid-template-rows auto
  grid-gap 12px
  align-items center
  margin-bottom 20px
  padding 12px 0 12px 12px

.email
  font-size 12px

.icon-button
  &:not(:hover)
    background none
</style>

~/work/vue-apollo/tests/demo/src/components/MessageForm.vue (改变)

<script>
- import MESSAGE_FRAGMENT from '../graphql/messageFragment.gql'
- import USER_FRAGMENT from '../graphql/userFragment.gql'
- 
export default {
  props: {
    channelId: {
      type: String,
      required: true,
    },
  },

  data () {
    return {
      newMessage: '',
    }
  },

  methods: {
    onDone () {
      this.newMessage = ''
      this.$refs.input.focus()
    },
  },
- 
-   fragments: {
-     message: MESSAGE_FRAGMENT,
-     user: USER_FRAGMENT,
-   },
}
</script>

<template>
  <ApolloMutation
-     :mutation="gql => gql`
-       mutation messageAdd ($input: MessageAdd!) {
-         messageAdd (input: $input) {
-           ...message
-         }
-       }
-       ${$options.fragments.message}
-       ${$options.fragments.user}
-     `"
+     :mutation="require('../graphql/messageAdd.gql')"
    :variables="{
      input: {
        channelId,
        content: newMessage,
      },
    }"
    class="message-form"
    @done="onDone"
  >
    <input
      slot-scope="{ mutate, loading, error }"
      ref="input"
      v-model="newMessage"
      :disabled="loading"
      class="form-input"
      placeholder="Type a message"
      @keyup.enter="newMessage && mutate()"
    >
  </ApolloMutation>
</template>

<style lang="stylus" scoped>
@import '~@/style/imports'

.message-form
  padding 12px
  width 100%
  box-sizing border-box

  .form-input
    display block
    box-sizing border-box
    width 100%
</style>

~/work/vue-apollo/tests/demo/src/components/MessageItem.vue(变更)

<script>
import marked from 'marked'

// Open links in new tab
const renderer = new marked.Renderer()
const linkRenderer = renderer.link
renderer.link = (href, title, text) => {
  const html = linkRenderer.call(renderer, href, title, text)
  return html.replace(/^<a /, '<a target="_blank" rel="nofollow" ')
}

export default {
  props: {
    message: {
      type: Object,
      required: true,
    },
  },

  computed: {
    html () {
      return marked(this.message.content, { renderer })
    },
  },
}
</script>

<template>
  <div class="message-item">
-     <div class="user">{{ message.user.nickname }}</div>
+     <div class="user">{{ message.userByUserId.nickname }}</div>
    <div class="content" v-html="html"/>
  </div>
</template>

<style lang="stylus" scoped>
@import '~@/style/imports'

.message-item
  padding 12px 12px
  &:hover
    background #f8f8f8

.user
  color #777
  font-size 13px
  margin-bottom 2px

.content
  word-wrap break-word

  >>>
    p
      margin 0

    img
      max-width 500px
      max-height 500px
</style>

~/work/vue-apollo/tests/demo/src/components/ChannelList.vue(改变)

<script>
import UserCurrent from './UserCurrent.vue'
import MockSendMessage from './MockSendMessage.vue'
- import gql from 'graphql-tag'

export default {
  name: 'ChannelList',

  components: {
    UserCurrent,
    MockSendMessage,
  },
- 
-   fragments: {
-     channel: gql`
-       fragment channel on Channel {
-         id
-         name
-       }
-     `,
-   },
}
</script>

<template>
  <div class="channel-list">
    <UserCurrent />

-     <ApolloQuery :query="gql => gql`
-       query channels {
-         channels {
-           ...channel
-         }
-       }
-       ${$options.fragments.channel}
-     `">
+     <ApolloQuery :query="require('../graphql/channels.gql')">
      <template slot-scope="{ result: { data, loading } }">
        <div v-if="loading" class="loading">Loading...</div>
        <div v-else-if="data" class="channels">
          <router-link
-             v-for="channel of data.channels"
+             v-for="channel of data.allChannels.nodes"
            :key="channel.id"
            :to="{ name: 'channel', params: { id: channel.id } }"
            class="channel"
          >
            <div class="id">#{{ channel.id }}</div>
            <div class="name">{{ channel.name }}</div>
          </router-link>
        </div>
      </template>
    </ApolloQuery>

    <MockSendMessage/>
  </div>
</template>

<style lang="stylus" scoped>
@import '~@/style/imports'

.channel-list
  background desaturate(darken($color, 60%), 95%)
  color white
  padding 12px

.channel
  display block
  padding 12px
  border-radius 4px
  &:hover
    background rgba($color, .3)
  &.router-link-active
    background $color
    color white
    font-weight bold

.id
  font-family monospace
  margin-bottom 4px
  font-size 14px

.name
  font-size 12px
  opacity .9
</style>

~/work/vue-apollo/tests/demo/src/components/ChannelView.vue (变更)

<script>
import MessageItem from './MessageItem.vue'
import MessageForm from './MessageForm.vue'

export default {
  name: 'ChannelView',

  components: {
    MessageItem,
    MessageForm,
  },

  props: {
    id: {
      type: String,
      required: true,
    },
  },

  watch: {
    id: {
      handler () {
        this.$_init = false
      },
      immediate: true,
    },
  },

  methods: {
    onMessageChanged (previousResult, { subscriptionData }) {
-       const { type, message } = subscriptionData.data.messageChanged
- 
-       // No list change
-       if (type === 'updated') return previousResult
- 
-       const messages = previousResult.channel.messages.slice()
-       // Add or remove item
-       if (type === 'added') {
-         messages.push(message)
-       } else if (type === 'removed') {
-         const index = messages.findIndex(m => m.id === message.id)
-         if (index !== -1) messages.splice(index, 1)
-       }
- 
-       // New query result
-       return {
-         channel: {
-           ...previousResult.channel,
-           messages,
-         },
-       }
+       const { message, oldrec, type } = subscriptionData.data.messageChanged
+ 
+       const nodes = previousResult.channelById.messagesByChannelId.nodes.slice()
+       if (type === 'INSERT') {
+         nodes.push(message)
+       } else {
+         const index = nodes.findIndex(m => m.id === oldrec.id)
+         if (index !== -1) {
+           if (type === 'DELETE') {
+             nodes.splice(index, 1)
+           } else if (type === 'UPDATE') {
+             nodes.splice(index, 1, message)
+           }
+         }
+       }
+ 
+       // New query result
+       return {
+         channelById: {
+           ...previousResult.channelById,
+           messagesByChannelId: {
+             ...previousResult.channelById.messagesByChannelId,
+             nodes,
+           },
+         },
+       }
    },

    async scrollToBottom (force = false) {
      let el = this.$refs.body

      // No body element yet
      if (!el) {
        setTimeout(() => this.scrollToBottom(force), 100)
        return
      }
      // User is scrolling up => no auto scroll
      if (!force && el.scrollTop + el.clientHeight < el.scrollHeight - 100) return

      // Scroll to bottom
      await this.$nextTick()
      el.scrollTop = el.scrollHeight
    },

    onResult (result) {
      // The first time we load a channel, we force scroll to bottom
      this.scrollToBottom(!this.$_init)
      this.$_init = true
    },
  },
}
</script>

<template>
  <div class="channel-view">
    <ApolloQuery
      :query="require('../graphql/channel.gql')"
      :variables="{
        id
      }"
      @result="onResult"
    >
      <template slot-scope="{ result: { data, loading } }">
        <div v-if="!data && loading" class="loading">Loading...</div>

        <div v-else-if="data">
          <!-- Websockets -->
          <ApolloSubscribeToMore
            :document="require('../graphql/messageChanged.gql')"
            :variables="{
              channelId: id,
            }"
            :updateQuery="onMessageChanged"
          />

          <div class="wrapper">
            <div class="header">
-               <div class="id">#{{ data.channel.id }}</div>
-               <div class="name">{{ data.channel.name }}</div>
+               <div class="id">#{{ data.channelById.id }}</div>
+               <div class="name">{{ data.channelById.name }}</div>
            </div>

            <div ref="body" class="body">
              <MessageItem
-                 v-for="message in data.channel.messages"
+                 v-for="message in data.channelById.messagesByChannelId.nodes"
                :key="message.id"
                :message="message"
              />
            </div>

            <div class="footer">
              <MessageForm :channel-id="id" />
            </div>
          </div>
        </div>
      </template>
    </ApolloQuery>
  </div>
</template>

<style lang="stylus" scoped>
@import '~@/style/imports'

.wrapper
  height 100vh
  display grid
  grid-template-columns 1fr
  grid-template-rows auto 1fr auto

.header
  padding 12px
  border-bottom $border

.id
  font-family monospace
  margin-bottom 4px

.name
  color #555

.body
  overflow-x hidden
  overflow-y auto

.footer
  border-top $border
</style>

以上是源代码修改完成。

运行PostGraphile版ApolloChat

那么我将试着运行它。
以下是执行操作,将启动Web服务器(Vue.js应用程序传输)。
由于PostGraphile已在Docker容器中启动,因此不需要单独启动GraphQL服务器。

cd ~/work/vue-apollo/tests/demo
yarn serve

确认能成功编译。
编译后显示如下。

 DONE  Compiled successfully in 3593ms                                       10:31:39


  App running at:
  - Local:   http://localhost:8080/demo/ 
  - Network: unavailable

  Note that the development build is not optimized.
  To create a production build, run yarn build.

按照指示,在浏览器中访问「http://localhost:8080/demo/」,将启动PostGraphile版ApolloChat。
首先会出现登录界面。

スクリーンショット 2019-06-19 10.33.31.png

我尝试使用Altair GraphQL Client创建的帐户登录。

Email:foo@foo.com
Password:p@ss
スクリーンショット 2019-06-19 10.35.41.png

我要试着进入一般频道。

スクリーンショット 2019-06-19 10.36.12.png

当试图执行GraphQL查询时,留下了一条消息。

    • messageChangedサブスクリプションを試行した時に、psqlからINSERTした「美味しい!」メッセージ

 

    mockMessageSendミューテーションをAltair GraphQL Clientで試行した時に投稿された「”How are you doing? ” + 連番」

那么,我们来点击左下角的“发送机器人消息”按钮试试看。

スクリーンショット 2019-06-19 10.45.23.png

「你好吗?+ 连番」增加了一条。
我将从右下角的消息输入栏发布一个”你好世界”。

スクリーンショット 2019-06-19 10.46.54.png
スクリーンショット 2019-06-19 11.34.41.png

有人发布了「你好,世界」。
现在我们来试试订阅。
从控制台进入Laradock的工作区,然后进入psql。

cd ~/work/laradock
docker-compose exec --user=laradock workspace bash
psql -h postgres -U default demodb

我将执行以下的SQL语句,并尝试显示消息列表。

SELECT * FROM apollo_demo.messages
  ORDER BY id;

显示如下。

 id | channel_id | user_id |       content        |         date_added         | date_updated 
----+------------+---------+----------------------+----------------------------+--------------
  1 | general    |       0 | Welcome to the chat! | 2019-06-18 07:10:27.671412 | 
  4 | general    |       1 | 美味しい!           | 2019-06-18 08:13:57.466309 | 
  5 | general    |       0 | How are you doing? 1 | 2019-06-18 08:26:07.808588 | 
  6 | general    |       0 | How are you doing? 2 | 2019-06-19 01:44:42.855632 | 
  7 | general    |       1 | こんにちは世界       | 2019-06-19 02:33:33.951225 | 

我将更新id为5的行,并尝试更新ApolloChat的数据。我会执行以下SQL语句。

UPDATE apollo_demo.messages SET
  content = 'Thank you.'
  WHERE id = 5;

ApolloChat的界面也进行了更新。

スクリーンショット 2019-06-19 11.46.57.png

我們將嘗試刪除id=5的行。請執行以下SQL語句。

DELETE FROM apollo_demo.messages
  WHERE id = 5;

ApolloChat的屏幕上也已经被删除了。

スクリーンショット 2019-06-19 11.48.33.png

我們接下來要插入一個新的行,並執行以下的SQL語句。

INSERT INTO apollo_demo.messages (channel_id, user_id, content) VALUES
    ('general', 1, 'Hello, World!');

这个功能还被添加到了ApolloChat的界面上。

スクリーンショット 2019-06-19 11.51.20.png
广告
将在 10 秒后关闭
bannerAds