Goalist Developers Blog

iOS10でプッシュ通知を実装してみた

こんちは。渡部です。

今回はプッシュ通知についてです。
プッシュ通知はリモートだと証明書やらで非常に面倒になります。
なので比較的に簡単な実装方法を紹介します。

f:id:watabe1028:20170831132743j:plain

プッシュ通知とは?

プッシュ通知とは、システム側が外部のサーバーと連携して能動的に情報を取得してユーザーに通知する方式のことである。

www.weblio.jp

要は外部から通知が届く仕組みです。

LINEとかメッセージ届くときますよね?
あれです。

動作環境

・macOS Sierra(10.12.6)

・Xcode8.3.2(古い!)

・Swift3.0(古い!)

・実機(iPhone SE iOS10.2 古い!)

注意点

・シミュレータでは検証できないので実機でおねしゃす!

・AppleDeveloperProgramには加入済み前提で進めます。
・証明書CSRファイルは取得済み前提です。

・今回はAdHocでやります。

・訳あってiOS、Xcodeとも古いままです。

・サーバサイドはノータッチ!

プッシュ通知の種類

リモートとローカル

簡単に言うと、ローカルはアプリ側のプログラムで通知が完結するもの

リモートは外部のサーバを使って通知を可能にするもの

と自分は定義しています。

プッシュ?リッチ?ペイロード?なんぞ?


通常のプッシュ通知のみの実装がプッシュ通知、
プッシュ通知を開くとWebViewが表示できるのがリッチプッシュ、
プッシュ通知からメッセージなどのデータをペイロード取得
する通知があります。

今回は面倒なのでシンプルなリモートのプッシュ通知です。
次回はローカルの記事を書くかもしれません。
書かないかもしれません。
詳しい仕組みは偉人たちの記事を参照してください(投げやり) 。

チャット風メッセージアプリを作ります。
Web側のメッセージ送信時にアプリがフォアグラウンドにない場合に
通知を表示させるだけの簡単な仕様です。
チャット? どうやって作んの?の人はこちら

今回は画像付きで長くなるで!

手順


・APNs証明書作成
・アプリの設定

・コーディング

・実行

APNs証明書作成

AppleDeveloperにログインし、「Certificates,IDs & Profiles」を選択します。
右ペインのメニューから「Certificates」を選択し、右上の「+」ボタンで証明書を作成します。

Apple Push Notification service SSL(Sandbox)を選択し

f:id:watabe1028:20170831152540p:plain

使いたいアプリのApp IDを選択します。

f:id:watabe1028:20170831152601p:plain

このページは華麗にスルー

f:id:watabe1028:20170831152616p:plain

CSRファイルを選択すればおけ!

f:id:watabe1028:20170831152632p:plain

アプリの設定

まずプロジェクトでプッシュ通知を使うための設定をします。
プロジェクトを選択し、「Capabilities」から「Push Notifications」と「Background Modes」をONにします。
f:id:watabe1028:20170831132308p:plain

「Background Modes」は「Background fetch」と「Remote Notifications」にチェックを入れます。 f:id:watabe1028:20170831132324p:plain

コーディング


プッシュ通知の基本ですが通知を受け取るには許可が必要です。

許可をもらった後に、デバイストークンを取得してサーバサイドに渡す必要があります。
いくつか注意点があるのでコメントを読んでください。

まずはインポートとDelegateの設定です。

import UIKit
import UserNotifications  // インポートする

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, networkManagerDelegate, UNUserNotificationCenterDelegate { // Delegateの追加を忘れずに

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

didFinishLaunchingWithOptionsで通知の設定をします。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
                
        if #available(iOS 10.0, *) {
            
            //ios10
            let center = UNUserNotificationCenter.current()
            center.requestAuthorization(options: [.badge, .sound, .alert], completionHandler: { (granted, error) in
                if error != nil {
                    return
                }
                if granted {
                    debugPrint("通知許可")
                    center.delegate = self
                    application.registerForRemoteNotifications()
                } else {
                    debugPrint("通知拒否")
                }
            })
            
        } else {
            // ios9
            let settings = UIUserNotificationSettings(types: [.badge, .sound, .alert], categories: nil)
            UIApplication.shared.registerUserNotificationSettings(settings)
            UIApplication.shared.registerForRemoteNotifications()
        }
        
        return true
    }

通知が許可された場合の処理です。
ここでデバイストークンを取得します。
以降もずっとこのデバイストークンを使うのでUserDefaults等に保存しておきます。

    //リモート通知
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        
        // そのままだと「32bit」という文字列なので以下の処理を行います
        let deviceTokenString: String = deviceToken.map { String(format: "%.2hhx", $0) }.joined()
        print("deviceTokenString \(deviceTokenString)")
        util.setUserDefaultsObject(value: deviceTokenString, key: udDeviceToken) // これは自前
    }

逆に通知が許可されなかった場合の処理です。

    //リモート通知を拒否したときの動作
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        
        debugPrint("リモート通知の設定は拒否されました")
    }

ここまで書けば一応動きます。
通知が来た時、通知から起動した時は
withCompletionHandlerなどを使います。
もちろん、はしょります。

実行


ビルドして実機にインストールします。
ここでビルド成功後にエラーが出たりしますがそこは頑張ってください。

多分プロビジョニングとかが悪さしてることが多いです。
今回は速攻で通知の許可アラートが表示されるはずです。
f:id:watabe1028:20170831131756p:plain

許可した後にアプリを一度バックグラウンドにします。
Web側でメッセージを送信! f:id:watabe1028:20170831131816p:plain

来た!
f:id:watabe1028:20170831131829p:plain

おまけ


サーバサイドはノータッチとしてましたが
今回はAWSのこちらを使っています。
aws.amazon.com

まとめ

準備から実装まで面倒なプッシュ通知ですが
ユーザーのアプリ復帰率を上げるためには必須と言えます。
通知自体もだんだんリッチになるので
今のうちに簡単な通知ができるようになった方が良いな、と思いこの記事を書きました。
どこかの誰かのお役に立てればと思います。

JenkinsでスタンドアローンなPlayFrameworkアプリをビルド・デプロイする

こんにちは。ゴーリスト開発のイイオです。

今回はJenkinsおじさんを使役します。
とはいえほんとうに手動でぽちぽちやっていたのを置き換えただけなので
もっと便利なプラグインとか使い方があるような気がします。

f:id:y-iio:20170825104518p:plain
よく知らなくてすまんな…おじさん

PlayFrameworkとは入社時から1年弱の付き合いですがいまだにあまり仲良くない。

https://www.playframework.com/documentation/ja/2.4.x

前提

ビルド環境

  • Ubuntu 14.04.5 LTS
  • Jenkins 2.46.2
  • PlayFramework 2.4 Java

実行環境

  • Amazon Linux AMI release 2017.03

JenkinsサーバはbitnamiでEC2上に立てた

なんかbitnami使ったおかげで楽だったりもしましたが、だいたい詰まったのこのせいな気がしなくもない。
それについてはこちらをご覧ください。

developers.goalist.co.jp

Playビルド用の環境整える

qiita.com

qiita.com

Jenkins > Global Tool Configuration > JDK > JDK追加
Java SE Development Kit 8u131

Jenkinsにリモートでシェル実行できるプラグイン入れる

Jenkins でリモートサーバーのコマンドを実行できる SSH pluginを使ってみた | Whaison JUGEM! StudyNoteBook .

Jenkins > 認証情報 > System > グローバルドメイン > 認証情報の追加
でリモートサーバー接続用のSSHユーザー名と秘密鍵を登録

Jenkins > 設定 > SSHリモートホスト
でリモートサーバーを登録

実行環境でアプリとPID置き場を作っておく

sudo mkdir /var/play
sudo mkdir /var/run/play

パーミッションも適当に与える

やってること

  1. Jenkinsでgithub上のソースから任意のブランチを指定してビルド
  2. リモートサーバーで稼働しているアプリ停止
  3. Jenkinsでビルドしてリモートサーバーの成果物更新
  4. リモートサーバーのアプリ再起動

ダウンタイムがある!ので
作業中はELBを別インスタンスに向けるとか、そのへんも自動化するとかあるだろうけど
漢らしく置いておくことにします٩( ‘ω’ )و

詳しく

1. Jenkinsでgithub上のソースから任意のブランチを指定してビルド

ビルド設定
こんなかんじでパラメータにブランチ名直書きでやってます。

f:id:y-iio:20170825104227p:plain

f:id:y-iio:20170825104326p:plain

なんか選択肢で選ぶ方法とかあるらしいけど複雑なのでやらない。

2. リモートサーバーで稼働しているアプリを停止

ビルド手順の追加 > リモートホストでシェルを実行

if test -e /var/run/play/play.pid; then kill $(cat /var/run/play/play.pid); fi
rm -rf /var/play/hrog-map-api-SNAPSHOT.zip
rm -rf /var/play/hrog-map-api-SNAPSHOT

3. Jenkinsでビルドして、リモートサーバーの成果物更新

ビルド手順の追加 > シェルの実行

bin/activator dist
scp -P 22 -i /path/to/key/key.pem ${WORKSPACE}/target/universal/PROJECT_NAME.zip USER@~~~~~~~.compute.amazonaws.com:/var/play

もっとこのへんいい方法あるんだろうな〜〜

4. リモートサーバーのアプリ再起動

ビルド手順の追加 > リモートホストでシェルを実行

unzip -o /var/play/hrog-map-api-SNAPSHOT.zip -d /var/play
nohup /var/play/hrog-map-api-SNAPSHOT/bin/hrog-map-api -Dconfig.resource=application_dev.conf -Dpidfile.path=/var/run/play/play.pid > out.log 2> err.log < /dev/null &

だめだったら

パーミッションを適切に与えればなんとかなる!!!根も葉もない

AWS Marketplaceからbitnamiを使ってEC2インスタンス上にJenkinsサーバーを立てる

こんにちは、ゴーリスト開発のイイオです。

bitnamiのオールインワンパッケージで
超楽にJenkinsおじさんを召喚できるのでは、という企ての話です。
実際のところ超楽だったかというと???なかんじですが…

bitnami.com

手順

bitnami Jenkinsの「See in AWS Marketplace」をクリック
f:id:y-iio:20170824172032p:plain

インスタンスタイプを選択し
f:id:y-iio:20170824172048p:plain

任意のVPC設定して囲み、自動割り当てパブリックIPを有効にする

qiita.com

タグ設定でわかりやすい名前をつけておく
f:id:y-iio:20170824172158p:plain

インスタンス作成ボタンを押してちょっと待つ

初期化が終わったら割り当ててくれたURLにアクセス
f:id:y-iio:20170824172248p:plain

ウェイ

特にポート設定とかしてないけどhttp://wariatehost/にアクセスすれば
http://wariatehost/jenkins/にフォワードしてくれる。
あとインスタンス起動したら勝手にJenkinsも起動してくれる。

user/bitnamiで初回ログイン

f:id:y-iio:20170824172350p:plain
ドキュメントにはインスタンスのシステムログからパスワード見ろって書かれてるけど
システムログに無いしuser/bitnamiでいけた。謎

勧められるがままに「Install suggested plugins」

で再読み込みしたら
f:id:y-iio:20170824172406p:plain
ウェイ

気をつけること

タイムゾーン変更する

インスタンス立てた時に登録したキーペアでssh接続して変更

ssh -i "/path/to/key/key.pem" ubuntu@~~~~~~~~~~~~~~~.compute.amazonaws.com
timedatectl set-timezone Asia/Tokyo

Jenkinsの実行ユーザーは「tomcat」

github接続時のキー登録するのに大変時間を無駄にしたので…

まあシステム情報見たらわかることでしたが
f:id:y-iio:20170824172448p:plain

tomcatユーザーになってから、こちら参考に鍵登録しました。

blog.duck8823.com

結論

普通にインスタンス立ててyumでインストールしたらいいとおもう。

qiita.com

おわり

Macのローカルサーバーに接続して、iPhone/iPadの実機で動作確認・デバックする

開発環境のMacで立てたローカルサーバに、iPadの実機端末からアクセスしたときのメモです。

実行環境

  • macOS Sierra 10.12.5
  • iOS 10.3.2
  • iPadとMacが同じwifiネットワークに接続している
  • Lightningケーブルで接続している

実機から開発環境のローカルサーバーにアクセス

このあたり参照して「hoge.local」でいけるかな〜と思ったらできず… yuji-ueda.hatenadiary.jp

qiita.com

結局IP指定で行きました tacamy.hatenablog.com

Angular CLI でng serveしてローカルサーバーたててるんですけど
このときにオプションで自分のIP直で指定しないと外から見れなかったです。

ng serve --host <MY_IP>

実機のSafariからみてみる
http://MY_IP:PORT

f:id:y-iio:20170823191813p:plain
ウェイ

デバッグする

PC側の開発者ツール使ってデバッグします。
こちらを参照しました。
www.tam-tam.co.jp

f:id:y-iio:20170823191918p:plain

f:id:y-iio:20170823191935p:plain

ウェイウェイ

感想

これで開発速度が65918723倍になりました。やったぜ

簡易O/Rマッパー作ってみた。

ども、開発部の小野です。

今、簡易O/Rマッパー作っています。今回は簡易O/Rマッパーについて書いてみます。

O/Rマッパーって何?

sqlを直で全部書かなくても、DBにsqlを投げてくれるやつです。

検索条件をちょっと書いてあげるだけで、sqlを投げてくれます。時短ですね。

実際

作ってみた簡易O/Rマッパーです。

public class Work1 {

	public static void main (String[] arg) throws SQLException{
		JdbcManager ajm = new JdbcManager("jdbc:mysql://localhost:3306/data?characterEncoding=UTF-8","root","password");
		List<Map<String,Object>> resultMap1 = ajm.selectBySql("select emp_nm from emp_mst").getResultList();
	}
}

このWork1クラスで簡易O/Rマッパーを実際に使っています。JdbcManagerがそれですね。

ではJdbcManagerクラスを見てみましょう。

public class JdbcManager {

	String db_url;
	String db_user_id;
	String db_user_password;

	public JdbcManager(String db_url,String db_user_id,String db_user_password){
		this.db_url = db_url;
		this.db_user_id = db_user_id;
		this.db_user_password = db_user_password;
	}

	public SqlSelect selectBySql(String sql){
		SqlSelect ss = new SqlSelect(this,sql);
		return ss;
	}

	public AutoSelect from(String tableNm){
		AutoSelect as = new AutoSelect(this, tableNm);
		return as;
	}

}

selectBySqlというメソッドがありますね。このメソッドの引数にsqlを渡して、検索を実行しています。

ではselectBySqlメソッドの戻り値である、SqlSelectクラスを見てみましょう。

public class SqlSelect {

	JdbcManager ajm;
	String sql;

	public SqlSelect(JdbcManager ajm, String sql) {
		this.ajm = ajm;
		this.sql = sql;
	}

	public List<Map<String, Object>> getResultList() throws SQLException{
		Select sl =new Select(this.ajm,this.sql);
		return sl.sqlExecute();
	}
}

getResultListというメソッドがありますね。このメソッドを実行することで、検索結果がListになって帰ってきます。上で見ると分かるようにMapのListですね。

メソッドの中を見てみると、さらにSelectクラスのslというインスタンスでsqlExecuteというメソッドを使用しています。

このメソッドで、実際にクエリーを実行しています。

Selectクラスを見てみましょう。

public class Select {

	JdbcManager ajm;
	String sql;

	public Select(JdbcManager ajm, String sql) {
		this.ajm = ajm;
		this.sql = sql+";";
	}

	public List<Map<String, Object>> sqlExecute() throws SQLException {

		try (Connection connection = DriverManager.getConnection(this.ajm.db_url, this.ajm.db_user_id,
				this.ajm.db_user_password); Statement statement = connection.createStatement();) {

			ResultSet rs = statement.executeQuery(this.sql);
			ResultSetMetaData rsmd = rs.getMetaData();

			List<Map<String, Object>> resultList = new ArrayList<Map<String, Object>>();

			while (rs.next()) {
				Map<String, Object> map = new HashMap<String, Object>();
				for (int i = 1; i <= rsmd.getColumnCount(); i++) {
					map.put(rsmd.getColumnName(i), rs.getObject(i));
				}
				resultList.add(map);
			}
			rs.close();

			return resultList;
		}
	}
}

ConnectionにJdbcManagerインスタンスに持たせた、DBのURL,user名,パスワードを渡して、DBサーバーに接続しています。

あとは、Work1クラスから持ってきたsqlをexecuteQueryでDBに投げ込んでみるだけです。単簡ですね。

帰ってきた結果をリストに整理してチョチョイです。

まとめ

O/Rマッパーで単簡に時短。

次はテーブル名やwhere句の要素だけ渡して、クエリを実行してくれるやつも紹介出来たらいいですね~。

HRエキスポに行ってきました。

f:id:bbbbbbbbb9:20170817163537j:plain

導入

こんにちは。
四月に新卒で入社して、開発部でエンジニアをしています。バンナイです。

2017/7/28にHRエキスポというものに参加してきました。

www.hr-expo.jp

その目的は

  • 人材業界または人事向けのサービスのチェック。どのようなサービスが存在しているのか知る。

  • 人材業界または人事の抱える問題をシステムで解決することに関する知見を深める。

  • 人材業界または人事の仕事のこれからの行く末を見極める。

です。

何か具体的な目的があるわけではなく、人材業界に関わっているエンジニアとして漠然と知見を深めるということが目的でした。

実際に見たサービス

Google Recruiting with AdWords

Recruiting with AdWordsとは

  • スマホは求職者が最も活用しているツールだが1回あたりの起動時間は1分11秒と短いため、スマホを多用する求職者に対して短時間で効率的にアプローチすることが求められる。
  • 短時間で会社の魅力を伝えるには動画が有効

という二つの前提を踏まえて、Googleが提案するソリューションだそうです。

一言で言うと『ターゲットとする求職者に狙いを定め、行動を喚起するYouTube動画広告』。

Googleの高度なターゲティング技術を使うと、年齢、趣味趣向、アクセス位置、使用しているデバイス等の情報を利用して動画広告を見てもらいたい層に集中的に動画広告を見せることができるという話を聞いて、Googleのターゲティング技術と求人サービスとの相性の良さに気づかされました。

また実際に明光義塾の動画広告を観させてもらったのですが、生徒と先生の関わり合いが丹念に描かれていてとても感情に訴えかけてくるものだなと思いました。

以上の理由からRecruiting with AdWordsはこれからの求人市場においてどんどん影響力を増していくのではないかという可能性を感じました。

ゴーリストは求人媒体のクローリングを行っているのですが、今後動画による求人が活性化してきたら動画のクローリングとかもできると面白いかもなと思いました。どうやればできるのか分からないけれど…

www.google.co.jp

リファラル採用を活性化するクラウドサービス リフカム

リファラル採用 = 企業に所属する社員・アルバイトから友人を紹介・推薦してもらう採用方法

リファラル採用におけるコミュニケーションコストの削減、リファラル採用の社内での定着を狙いとしたサービス。

  • 社員に紹介を依頼する。
  • 社員の紹介の手間を減らす。
  • リファラル採用の効果測定と応募者管理。

ということができるとのことでした。
リファラル採用というとどうしても属人的になりがちというイメージがあったので、リファラル採用をシステムを運用できるのだったら興味深いサービスだなと思いました。

実際に使用画面を見せてもらったが、誰がいつ何人友達を紹介してくれたとかが、視覚的にとても分かりやすかった。

手間やコミュニケーションコストがボトルネックになって今まで出来なかったことをできるようにするというのはシステムが目指すべき一つの形だと思います。

refcome.com

採用HP作成ツール[エンゲージ]

誰でもカンタンに無料で採用HP作成ができるサービス。

実際にこのサービスでつくられたサービスをいくつか見せてもらったがかなり見た目よくリッチな感じでした。

綺麗なホームページが誰でも簡単に作れてしまうということで個人的には衝撃を受けました。 「どうして無料なんですか?」と質問してみました。

「このサービスはエンジャパンという求人媒体も持っている会社が提供しており各企業のホームページがリッチになればそれだけ人と企業の良い出会いがもっと生まれそれはエンジャパンにとってもいいこと」だそうです。
興味深い考え方だと思いました。

簡単にリッチなものが作れるというわけでまさにシステムが解決すべき問題であったと思いました。

en-gage.net

総括

最新のサービスに触れてサービスや業界の動向について学ぶことは重要だと思いました。

ゴーリスト開発部では個々人が皆プロダクトマネージャーになることが求められます。 またゴーリスト開発部では技術は手段であり本来の目的 = プロダクト、提供される価値等、から逸れないことも求められます。

今後も定期的にこのような取り組みをしていきたいです。

angular-google-mapsとカスタムオーバーレイでDOMをマーカーっぽく使う

こんにちは。ゴーリスト開発のイイオです。

先輩エンジニアモリツグさんの投稿で覚えもめでたい、angular-google-mapsでカスタムオーバーレイの続編です。

developers.goalist.co.jp

今回はカスタムオーバーレイでDOMをマーカーっぽく描画する必要に迫られた場合の解決方法を記しておきます。

やったことまとめ

  1. カスタムオーバーレイを描画する
  2. カスタムオーバーレイを緯度経度指定で描画してマーカーっぽく見せる
  3. カスタムオーバーレイにクリックイベントをつける

とりあえずできたものはこんな感じに動かせます。

f:id:y-iio:20170808153347g:plain

マーカーを表示して
ズームインしたらうまいこと位置調整が入って
マーカークリックイベント拾ってアラート表示
マーカーを非表示にする
みたいな流れです。

ここからが解説だ

何は無くともangular-google-mapsを入れます。
npm i --save @agm/core

app.module.tsにインポート文追加します。
APIキーはご自分のをドウゾ。

@NgModule({
  ・・・
  imports: [
    AgmCoreModule.forRoot({
      apiKey: 'YOUR_API_KEY'
    })
  ],
  ・・・
})

コンパイル時に google is not defined で怒られるので型定義も入れておきます。
npm i --save @types/googlemaps

使うところでインポート文を入れます。
import {} from '@types/googlemaps';

angular-google-maps公式のGetting startedを見ながら地図を表示させる。
いや決してめんどくさくなったわけではオボボボボ
CSSで高さ指定しないと表示できないので注意です。

で、先人の知恵を見ながらこんな感じに書いてみた。
任意のタイミングでマーカー表示/非表示したいのでちょっと変えてます。

app.component.ts

import { Component } from '@angular/core';
import {} from '@types/googlemaps';
import { GoogleMap } from '@agm/core/services/google-maps-types';

const cont: object = {
  label: '株式会社ゴーリスト',
  lat: 35.695568,
  lng: 139.771055
};

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  public lat: number = 35.695568;
  public lng: number = 139.771055;
  public zoom: number = 15;
  private nativeMap: GoogleMap;
  private overlays: any[] = [];

  public onMapReady(map: GoogleMap): void {
    this.nativeMap = map;
  }

  public toggleShowMarker(): void {
    if (this.overlays.length > 0) {
      this.removeMarker();
    } else {
      this.drawMarker();
    }
  }

  private drawMarker(): void {

    class CustomOverlay extends google.maps.OverlayView {
      map_;
      content_;
      div_;

      constructor(map, content, self) {
        super();

        this.map_ = map;
        this.content_ = content
        this.div_ = null;

        this.setMap(map)

        self.overlays.push(this);
      }
      /**
       * onAdd is called when the map's panes are ready and the overlay has been
       * added to the map.
       */
      onAdd() {
        const div = document.createElement('div');
        div.style.borderStyle = 'none';
        div.style.borderWidth = '0px';
        div.style.position = 'absolute';
        div.innerHTML = '<div class="marker">' + this.content_.label + '</div>';

        this.div_ = div;

        // Add the element to the "overlayLayer" pane.
        const panes = this.getPanes();
        panes.overlayLayer.appendChild(div);

        // Add click event
        panes.overlayMouseTarget.appendChild(div);
        google.maps.event.addDomListener(div, 'click', () => {
          self.onMarkerClicked();
        });
        google.maps.event.trigger(this, 'ready');
      };

      draw() {
        const overlayProjection = this.getProjection();
        const pixel = overlayProjection.fromLatLngToDivPixel(
          new google.maps.LatLng(this.content_.lat, this.content_.lng)
        );

        // Resize the div to fit the indicated dimensions.
        const el = this.div_;
        const content = el.children[0];
        const content_height = content.clientHeight;
        const content_width = content.clientWidth;
        el.style.top = pixel.y - (content_height + 7) + 'px';
        el.style.left = pixel.x - (content_width / 2) + 'px';
      };

      // The onRemove() method will be called automatically from the API if
      // we ever set the overlay's map property to 'null'.
      onRemove() {
        this.div_.parentNode.removeChild(this.div_);
        this.div_ = null;
      };

    }

    const self = this;
    if (cont) {
      new CustomOverlay(this.nativeMap, cont, self);
    }
  }

  private removeMarker(): void {
    this.overlays.forEach((item) => {
      item.setMap(null);
    });
    this.overlays = [];
  }

  private onMarkerClicked(): void {
    alert('marker clicked');
  }
}

onMapReadyでネイティブな Google Mapオブジェクトを変数に特攻ブッコむ。

drawMarkerのインナークラスでカスタムオーバレイを上書き、
newしたカスタムオーバーレイを配列に入れて保持。
(もともと複数マーカー表示していたのでこんなんなっている)

クリックイベントはDOM作ってからgoogle.maps.event.addDomListener()のところで指定。

描画時の位置は OverlayView.getProjection().fromLatLngToDivPixel()で緯度経度から特定。
吹き出しの矢印がちょうど緯度経度の位置に刺さるようにtopとleftのスタイル調整。
地図リサイズ時にdraw()がよびだされるのでその度に表示位置は変わる。

removeMarkerで作ったカスタムオーバーレイを破棄する。
OverlayView.setMap(null);のところ)

みたいなかんじです。

他の部分のソースはこちら

マーカーのCSSはapp.component.scssに書いてもかからなかったのだけど
src/styles.scssのほうでグローバル指定したらいけました。

app.component.html

<agm-map [latitude]="lat" [longitude]="lng" [zoom]="zoom" (mapReady)="onMapReady($event)"></agm-map>
<button (click)="toggleShowMarker()">マーカー表示</button>

app.component.scss

agm-map {
  height: 500px;
  width: 500px;
}

src/styles.scss

/* You can add global styles to this file, and also import other style files */
.marker {
  padding: 5px 6px;
  border-radius: 3px;
  width: auto;
  background: tomato;
  color: #fff;
  opacity: 0.9;
  cursor: pointer;

  &:after {
    top: 100%;
    left: 50%;
    border: solid transparent;
    content: " ";
    height: 0;
    width: 0;
    position: absolute;
    pointer-events: none;
    border-color: rgba(255, 53, 31, 0);
    border-top-color: tomato;
    border-width: 12px 6px 0 6px;
    margin-left: -6px;
  }
}

まとめ

angular-google-mapsの日本語記事なさすぎて嘆きます。
そしてほんとうに
コンポーネントをカスタムオーバーレイに突っ込めるような修正がangular-google-mapsに入ることを期待しています!!!