24
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Meteorが1.0になったので入門してみた(2/2)

Last updated at Posted at 2014-11-02

前回までの「Meteorが1.0になったので入門してみた」

Meteorがリアクティブ・フレームワークだってどういうことなの。って所まで学びました。

Meteorが1.0になったので入門してみた(1/2)

Post登録画面を作る

Postの登録画面から作ります。

client/templates/posts/post_submit.html
<template name="postSubmit">
  <form class="main form">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>

ルーティングの設定も

lib/router.js
	Router.configure({
	  layoutTemplate: 'layout',
	  waitOn: function() { 
	  	console.log("run waitOn......");
	  	return Meteor.subscribe('posts'); 
	  },
	  notFoundTemplate: 'notFound',
	});

	Router.route('/', {name: 'postsList'});
	Router.route('/posts/:_id', {
	  name: 'postPage',
	  data: function() { return Posts.findOne(this.params._id); }
	});

+   Router.route('/submit', {name: 'postSubmit'});

	Router.onBeforeAction('dataNotFound', {only: 'postPage'});

ヘッダにリンクを追加

client/templates/includes/header.html
       <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
       </div>
       <div class="collapse navbar-collapse" id="navigation">
+        <ul class="nav navbar-nav">
+          <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
+        </ul>
         <ul class="nav navbar-nav navbar-right">
           {{> loginButtons}}
         </ul>

登録処理の追加

client/templates/posts/post_submit.js
Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();//formのsubmit処理無効化

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});

おお、クライアントの処理がこんなにシンプルとは・・・。
そして普通にJQuery使えるんですね。

Posts.insert(post)関数は成功時に生成された_idを返してくれます。

さて、簡単にPost作成処理はできましたが、今いくつかの問題を抱えています。

  • 誰でも登録できてしまう。
     今のままでは誰でも登録してしまえるのでやりたい放題です。
     ログインユーザからしか登録できないようにしましょう。

  • ブラウザコンソールから登録できてしまう
    ブラウザから簡単に登録できてしまいます。これも無効化しなくては!!!

Terminal
meteor remove insecure

insecureパッケージを削除することで無効化しました。
ブラウザコンソールから削除できないことを確認してみてください

ブラウザコンソール
Posts.insert({title:"Hello"})
"zjrz2woBSmKd7e269"
insert failed: Access denied 

おお、、、、

でも、クライアントから全面的に登録不可となったので、
暫定的に登録可能にします。

lib/posts.js
 Posts = new Mongo.Collection('posts');
+Posts.allow({
+  insert: function(userId, doc) {
+    // only allow posting if you are logged in
+    return !! userId;
+  }
+});

本当に暫定的ですが、loginしていないときはuserIdはnullとなるので、!!をつけることでBooleann型に変換してるわけですね。

登録自体は無効化しましたが登録画面にはまだ誰でもいけます。

登録画面へのルートを潰す処理を入れます。

lib/router.js
outer.configure({
  layoutTemplate: 'layout',
  waitOn: function() { 
  	return Meteor.subscribe('posts'); 
  },
  notFoundTemplate: 'notFound',
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

+ var requireLogin = function() {
+  if (! Meteor.user()) {
+    this.render('accessDenied');
+  } else {
+    this.next();
+  }
+ }
 Router.onBeforeAction('dataNotFound', {only: 'postPage'});
+Router.onBeforeAction(requireLogin, {only: 'postSubmit'});

ログインしていない場合の表示画面用Templateを追加

client/templates/includes/access_denied.html
<template name="accessDenied">
  <div class="access-denied jumbotron">
    <h2>Access Denied</h2>
    <p>You can't get here! Please log in.</p>
  </div>
</template>

ところで、素晴らしいことに ルーテイングですらリアクティブなんです。
これの意味することは、例えばログイン処理をした後の処理をコールバックに登録。とか考慮不要だということです。

この場合ログイン状態が変化するだけで、accessDenied Template から postSubmit Templateに変化してしまいます。
これはブラウザのタブを複数開いて同じ画面を開いていても起きますんで試してみてください。(びっくりしました。)

ただサーバにログイン状態を確認するために時間が必要なため、
その状態も表示を合わせておきましょう。

lib/router.js
outer.configure({
  layoutTemplate: 'layout',
  waitOn: function() { 
  	return Meteor.subscribe('posts'); 
  },
  notFoundTemplate: 'notFound',
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

 var requireLogin = function() {
  if (! Meteor.user()) {
-    this.render('accessDenied');
+    if (Meteor.loggingIn()) {
+          this.render(this.loadingTemplate);
+     } else {
+        this.render('accessDenied');
+     }  
  } else {
    this.next();
  }
 }
 Router.onBeforeAction('dataNotFound', {only: 'postPage'});
 Router.onBeforeAction(requireLogin, {only: 'postSubmit'});

あとはログアウト中はリンクも非表示にします。

client/templates/includes/header.html
       </div>
       <div class="collapse navbar-collapse" id="navigation">
         <ul class="nav navbar-nav">
-          <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
+          {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}</li>
         <ul class="nav navbar-nav navbar-right">
           {{> loginButtons}}

currentUser helperはaccountパッケージにて提供されており。
Meteor.user()に対応してます。

Post登録処理の改善

前章で一通り登録処理はできましたが、実は問題が幾つかあります。

  • postのタイムスタンプの問題
  • 登録時にURLが重複しないようにしたい
  • ユーザ情報をpost内容に登録したい。

上記3点はクライアントサイドだけでは改善しません。

  • postのタイムスタンプの問題
    → クライアントの時計がずれている可能性がある

  • 登録時にURLが重複しないようにしたい
    →クライアントは全レコードを保持できないため、検証できない

  • ユーザ情報をpost内容に登録したい。
    →ユーザ情報をクライアントに保持させていると、改ざんのおそれがある

Meteorには Meteor Method というものがあります。
これはサーバサイドに実装されており、クライアントサイドから呼びだせる関数です。

実はCollectionのinsert update removeもMethodだったのです。
(insertしか使ってないけど・・・)

このMethodを自分たちで作ってしまいましょう。

client/templates/includes/header.html
   'submit form': function(e) {
     e.preventDefault();

     var post = {
       url: $(e.target).find('[name=url]').val(),
       title: $(e.target).find('[name=title]').val()
     };

-    post._id = Posts.insert(post);
-    Router.go('postPage', post);
+    Meteor.call('postInsert', post, function(error, result) {
+      // display the error to the user and abort
+      if (error)
+        return alert(error.reason);
+      Router.go('postPage', {_id: result._id});
+    });
   }
 });

Meteor.callはMethod名,オブジェクト,完了時のコールバックを引数に取ります。
Meteor methodのコールバックは常に引数をerror,resultの順に取ります。
もしerrorが存在すれば常にユーザに知らせてください。
その際にコールバック内部でreturnしてコールバックを止めます。
もしすべてうまく動いているならリダイレクトしてpostPageに飛ばしましょう。

この段階で登録処理をして怒られてみましょう。
「Method not found」って怒られればOKです。
このメソッドpostInsert を作ります。
allowメソッドは削除してinsertを使えないようにします。

lib/posts.js
Posts = new Mongo.Collection('posts');

-Posts.allow({
-  insert: function(userId, doc) {
-    // only allow posting if you are logged in
-    return !! userId;
+
+Meteor.methods({
+  postInsert: function(postAttributes) {
+    check(Meteor.userId(), String);
+    check(postAttributes, {
+      title: String,
+      url: String
+    });
+    var user = Meteor.user();
+    var post = _.extend(postAttributes, {
+      userId: user._id,
+      author: user.username,
+      submitted: new Date()
+    });
+    var postId = Posts.insert(post);
+    return {
+      _id: postId
+    };
   }
 });

insertするためにはpostAttributesを継承して要素を追加する必要があります。
check関数はaudit-argument-checks pacakgeを参照

URL重複チェック

以下のように処理追加

lib/posts.js
 Meteor.methods({
   postInsert: function(postAttributes) {
     check(Meteor.userId(), String);
     check(postAttributes, {
       title: String,
       url: String
     });
+
+    var postWithSameLink = Posts.findOne({url: postAttributes.url});
+    if (postWithSameLink) {
+       console.log("exist postWithSameLink");
+      return {
+        postExists: true,
+        _id: postWithSameLink._id
+      }
+    }
+
     var user = Meteor.user();
     var post = _.extend(postAttributes, {
       userId: user._id,

urlが重複した場合は登録処理を行わずにidを返却するようにしたので、
その対応を入れます。

client/templates/posts/post_submit.js

       // display the error to the user and abort
       if (error)
         return alert(error.reason);
+
+      if (result.postExists)
+        alert('This link has already been posted');
+
       Router.go('postPage', {_id: result._id});
     });
   }

先ほどsubmittedを追加しましたので、それでソートする仕様にします。

client/templates/posts/posts_list.js
Template.postsList.helpers({
  posts: function() {
-    return Posts.find();
+    return Posts.find({}, {sort: {submitted: -1}});
  }
});

##なんでMeteor Methodはclient/server共有エリア(/lib)に置かれるのか

表題の疑問が解決したのでこちらにまとめます。
またまた、びっくりしました。
実はクライアントでもサーバでもこのメソッドは動いていたんです。
なんでそんなことをするのか!。

普通WEBアプリの挙動としては、DBのレコードを更新しようとすると
サーバからの返事を待たなければなりません。
普通のWebアプリならまぁ許容できなくもないです。
でもMeteorをつかってリアルタイムにグリグリ動くようなWEBアプリを作るとなると、許容できません。

で、どうするか。
クライアントサイドでDBの動きをシュミレートしてすぐ終わったように見せかけちゃおう!
ってことだったんです。そのためにminiMongoなるものを作っていたんです。
基本的にはシミュレートした値と実際の値は一致するのですぐレスポンスが帰ってきたように見えます。

不一致だったりした場合はレスポンスが帰ってきたタイミングでこっそり修正してしまいます。

先ほど作ったMethodを修正して実験することができます。

lib/posts.js
Meteor.methods({
  postInsert: function(postAttributes) {
  	console.log("run postInsert");

    check(Meteor.userId(), String);
    check(postAttributes, {
      title: String,
       url: String
     });

+    if (Meteor.isServer) {
+      postAttributes.title += "(server)";
+      // wait for 5 seconds
+      Meteor._sleepForMs(5000);
+    } else {
+      postAttributes.title += "(client)";
+    }
+
     var postWithSameLink = Posts.findOne({url: postAttributes.url});
     if (postWithSameLink) {

サーバで実行される場合のみ5秒待ち合わせるように修正しました。
さらにtitleにクライアント側による書き換えかサーバ側による書き換えかわかるようにします。

でもこれだけではダメです。

もう一つ以下のような修正が必要です。

client/templates/posts/post_submit.js
Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      console.log("postInsert callback run...");
      // display the error to the user and abort
      if (error)
        return alert(error.reason);

       if (result.postExists)
         alert('This link has already been posted');

-      Router.go('postPage', {_id: result._id});
     });
+      Router.go('postsList');
   }
 });

もともとの遷移処理がDBの結果を待ってから遷移する挙動になっていました。
これを待たずに遷移するように変更します。

クライアント側の結果による書き換えと、その後のサーバ側からの修正による書き換えが確認できたでしょうか?すごいですよね。

でも、挙動を考慮して作らないと、結局サーバの応答待ちになっちゃうので、難しいですね。

編集と削除

ルーティングの追加から。
編集画面で削除できるようにします。

lib/router.js

  data: function() { return Posts.findOne(this.params._id); }
 });

+Router.route('/posts/:_id/edit', {
+  name: 'postEdit',
+  data: function() { return Posts.findOne(this.params._id); }
+});
+
+
 Router.route('/submit', {name: 'postSubmit'});

編集権削除画面追加

client/templates/posts/post_edit.html
<template name="postEdit">
  <form class="main form">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>

postEdit helper 作成

client/templates/posts/post_edit.js

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        alert(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});

post_item Templateに編集画面のリンクを追加
オーナーしか編集できないようにする。

client/templates/posts/post_item.html

          <div class="post">
            <div class="post-content">
              <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
+             <p>
+               submitted by {{author}}
+               {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
+         </p>
            </div>
            <a href="{{pathFor 'postPage' }}" class="discuss btn btn-default">Discuss</a>
          </div>

ownerかどうかが、わかるように
ownPost helperを追加

client/templates/posts/post_item.js
 Template.postItem.helpers({
+
+  ownPost: function() {
+    return this.userId === Meteor.userId();
+  },
   domain: function() {
     var a = document.createElement('a');
     a.href = this.url;

これだけでは実は修正や削除に失敗します。
だって、insecureパッケージ削除しましたもんね。

ということでallowメソッドが復活します。
それとEdit対象のFieldによって処理を拒否する処理も追加しています。

lib/posts.js
});
+
+Posts.allow({
+  update: function(userId, doc) { return doc && doc.userId === userId; },
+  remove: function(userId, doc) { return doc && doc.userId === userId; },
+});
+
+Posts.deny({
+  update: function(userId, post, fieldNames) {
//undescore.jsの機能 url,title以外のフィールドが存在したらtrueが
+    // may only edit the following two fields:
+    return (_.without(fieldNames, 'url', 'title').length > 0); 
+  }
+});

これで対応完了です。お疲れさまでした。
結局、edit,deleteはMeteor Methodを使いませんでした。
この辺の話はこちらを参照

24
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?