コンパイル時の注釈付きハッシュ文字列

最近、Android向けのアプリケーションの開発を開始し、そのアプリケーションを逆引きから保護するタスクがありました。 Googleをざっと見てみると、Android Studioの一部であるProGuardがこのタスクに対処することを示唆しています。 結果は、1つの小さなディテール-ラインを除いて本当に私に合っていました。

プログラムは、Intentを使用してサービスと情報を交換します。 その重要な部分はアクションラインです。 また、システムや他のアプリケーションとやり取りする場合、文字列は特定の形式である必要があり、アプリケーション内での交換にはその一意性で十分です。 便宜上、この行はパッケージ名とアクション名で構成することをお勧めします。 例:

public final class HandlerConst { public static final String ACTION_LOGIN = "com.example.app.ACTION_LOGIN"; }
      
      





これはデバッグに便利ですが、コードの難読化の品質を大幅に低下させます。 たとえば、プログラムリリースでは、この行の代わりにMD5ハッシュを表示したいと思います。

 public final class HandlerConst { public static final String ACTION_LOGIN = "7f315954193d1fd99b017081ef8acdc3"; }
      
      





カットの下で、手元の自転車の助けを借りてこの動作を達成する方法が説明されています。



いくつかの歌詞


ProGuardが文字列で動作しないことを知って非常に驚きました。 公式Webサイトのドキュメントから、高度な有料バージョンが文字列を処理できることがわかりました。 プログラムの実行中に文字列を暗号化して元のバージョンに復号化するだけです。 文字列をMD5値に変換するソリューションが見つかりませんでした。

この問題の解決策を見つける試みは、C ++コンパイラを最適化することの不思議を示す記事へと導きました: コンパイル時のCRC32行の計算 。 しかし、Javaでは、同様の方法はうまくいきませんでした。 ProGuardはメソッドをかなり削減しましたが、文字列からバイトの配列を取得することにつまずきました。

その後、私は手で問題を自動化して解決するために時間を無駄にしないことに決めました。

 public final class HandlerConst { public static final String ACTION_LOGIN; static { if (BuildConfig.DEBUG) ACTION_LOGIN = "com.example.app.ACTION_LOGIN"; else ACTION_LOGIN = "7f315954193d1fd99b017081ef8acdc3"; } }
      
      





しかし、ハブでカスタム注釈プリプロセッサの記事を見て、IntelliJ IDEAAndroidベースのアプリケーションと構成を作成すると 、これが私の問題の解決策であることがわかりました。



アノテーションを実装する


伝統に従った注釈の研究は、ロシア語で必要な情報の欠如から始まりました。 ほとんどの記事では、ランタイムアノテーションの使用について説明しています。 ただし、適切な記事がHabréで見つかりました: 注釈を使用したメソッドの実行時間のカウント

コンパイル時の注釈を作成するには、次のものが必要です。

  1. 注釈を説明してください。
  2. アノテーションを処理するAbstractProcessorクラスの子孫を実装します。
  3. コンパイラーにプロセッサーの検索場所を伝えます。




注釈の説明は次のようになります。

 package com.example.annotation; @Target({ElementType.FIELD}) @Retention(RetentionPolicy.SOURCE) public @interface Hashed { String method() default "MD5"; }
      
      





ターゲット -注釈を適用できるオブジェクトを定義します。 この場合、アノテーションはクラス内の変数宣言に適用できます。 残念ながら、誰にとっても、それについては後で詳しく説明します。

保持 -注釈の有効期間。 ソースコードにのみ存在することを示します。

アノテーション自体に、ハッシュメソッドを定義するフィールドを設定します。 デフォルトはMD5です。

これはコードで注釈を使用するのに十分ですが、注釈ハンドラを作成するまでは意味がありません。



注釈ハンドラーはjavax.annotation.processing.AbstractProcessorを継承します。 最小のハンドラクラスは次のようになります。

 package com.example.annotation; @SupportedAnnotationTypes(value = {"com.example.annotation.Hashed"}) @SupportedSourceVersion(SourceVersion.RELEASE_7) public class HashedAnnotationProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { return false; } }
      
      





SupportedAnnotationTypes-プロセッサによって処理される注釈クラスの名前を定義します。

SupportedSourceVersion-ソースのサポートされているバージョン。 ポイントは、注釈を処理するときに、新しいバージョンの言語に出現した言語構造をプロセッサが壊さないことです。

これらの注釈の代わりに、 getSupportedAnnotationTypesおよびgetSupportedSourceVersionメソッドをオーバーライドできます。

processメソッドは、サポートされている生の注釈のリストと、コンパイラと対話するためのオブジェクトを取得します。 メソッドがfalseを返す場合、コンパイラーは、このタイプの注釈をサポートする次のプロセッサーに処理のために注釈を渡します。 メソッドが真実を返した場合、注釈は処理されたと見なされ、他のどこにも到達しません。 これは、他の人の注釈を誤って釘付けにしないように考慮する必要があります。

プロセッサの動作中にソースコードが変更または追加された場合、コンパイラは次のパスに進みます。



RoundEnvironmentはソースコードを変更するのに十分ではないため、 initメソッドを再定義し、 そこからJavacProcessingEnvironmentを取得します。 このクラスを使用すると、ソースコード、警告やコンパイルエラーをスローするシステムなどにアクセスできます。 そこで、ソースコードを変更するための補助ツールであるTreeMakerを取得します。

  private JavacProcessingEnvironment javacProcessingEnv; private TreeMaker maker; @Override public void init(ProcessingEnvironment procEnv) { super.init(procEnv); this.javacProcessingEnv = (JavacProcessingEnvironment) procEnv; this.maker = TreeMaker.instance(javacProcessingEnv.getContext()); }
      
      







ここで、注釈付きフィールドを反復処理し、文字列定数の値を置き換えるだけです。 コードを略して示します。 記事の最後にあるGitHubへのリンク。

  @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { if ( annotations == null || annotations.isEmpty()) { return false; } for (TypeElement annotation : annotations) { //   ,      final Set<? extends Element> fields = roundEnv.getElementsAnnotatedWith(annotation); JavacElements utils = javacProcessingEnv.getElementUtils(); for (final Element field : fields) { // ,      . Hashed hashed = field.getAnnotation(Hashed.class); //     JCTree blockNode = utils.getTree(field); if (blockNode instanceof JCTree.JCVariableDecl) { //,       . JCTree.JCVariableDecl var = (JCTree.JCVariableDecl) blockNode; //  (    = ) JCTree.JCExpression initializer = var.getInitializer(); //      ,     : // "" + 1 // new String("new string") if ((initializer != null) && (initializer instanceof JCTree.JCLiteral)){ JCTree.JCLiteral lit = (JCTree.JCLiteral) initializer; //  String value = lit.getValue().toString(); try { MessageDigest md = MessageDigest.getInstance(hashed.method()); //      . md.update(value.getBytes("UTF-8")); byte[] hash = md.digest(); StringBuilder str = new StringBuilder(hash.length * 2); for (byte val : hash) { str.append(String.format("%02X", val & 0xFF)); } value = str.toString(); lit = maker.Literal(value); var.init = lit; } catch (NoSuchAlgorithmException e) { // :    } catch (UnsupportedEncodingException e) { // :   ?? } }else{ // :   . } } } } }
      
      





このメソッドでは、注釈のリストを実行します(一般的な場合、プロセッサが複数の注釈を処理することを覚えていますか?)。各注釈について、要素のリストを選択します。 その後、魔法が始まります。 com.sun.tools.javacで提供されるツールを使用して、要素をソースツリーに変換します。ソースツリーには、膨大な数の機能があり、伝統的にロシア語のドキュメントがまったくありません。 したがって、このツリーを操作するためのコードが理想からほど遠いことに驚かないでください。

JCTree.JCVariableDecl var treeの形式で変数宣言を受け取ったとき、それが文字列変数であることを確認できます。 私の場合、このチェックは松葉杖で実行されます。

 if (!"String".equals(var.vartype.toString())){ // :     . continue; }
      
      





vartypeは確かにいくつかの定数と比較したり、特定のクラスに属しているかどうかを判断したりすることができるフィールドのタイプですが、先ほど述べたように、ドキュメントはなく、簡単なチェックで文字列にキャストするとタイプ名が得られることがわかりました。



2番目の興味深い点は、記事の最初から例に似た行しか処理できないことです。 問題は、この段階でソーステキストを使用していることです。 したがって、変数がコンストラクターで初期化される場合、 JCTree.JCExpression initializer = var.getInitializer(); nullを返します 。 フォームの構成を処理しようとすると、同様に不快な状況が発生します。

 public String demo1 = new String("habrahabr"); public String demo2 = "habra"+"habr"; public String demo3 = "" + 1;
      
      





これを行うために、2番目のチェック(JCTree.JCLiteralの初期化インスタンス)が導入されます 。 これは、純粋な形式のリテラルではなく、いくつかの要素の表現によってツリーで表されるため、説明されているすべての例を切り取ります。

それ以上のコードは明らかです。 私たちは線を引いて、ハッシュし、交換し、喜びますか? いや

コメントは、明らかなエラーが発生するいくつかの場所を示しています。 そして、私たちの場合、それらを無視することは正しい動作ではありません。 エラーについてユーザーに通知するには、 javax.annotation.processing.Messagerオブジェクトが必要です。 これにより、警告、コンパイルエラー、または単なる情報メッセージをスローできます。 たとえば、無効なハッシュアルゴリズムを報告する場合があります。

 catch (NoSuchAlgorithmException e) { javacProcessingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, String.format("Unsupported digest method %s", hashed.method()), field); }
      
      





エラーメッセージをスローしても、メソッドの実行は中断されないことを理解してください。 コンパイラは、少なくともメソッドの終了を待ってから、コンパイルプロセスを中断します。 これにより、ユーザーに注釈を適用する際のすべてのエラーをすぐに捨てることができます。 printMessageメソッドの3番目の引数により、つまずいたオブジェクトを指定できます。 必須ではありませんが、人生を大いに促進します。



注釈プロセッサを接続


引き裂くための注釈を受け入れる準備ができており、準備ができていることをコンパイラに伝えることは残っています。 多くの記事で、プロセッサを<開発環境名>に追加する方法の説明があります。 どうやら、これは古代に遡り、そのようなことは職人によってひざの上で行われていました。 ただし、かなり前に、注釈を処理するメカニズムはjavacの一部であり、実際、クラスハンドラーはjavacのプラグインです。 これは、非常に標準的な手段を使用して、設定でシャーマニズムなしで任意の環境に注釈を接続できることを意味します。

META-INFディレクトリにサービスのサブディレクトリを作成し、その中にjavax.annotation.processing.Processorファイルを作成する必要があります。 ファイル自体に、プロセッサクラスのリストを配置する必要があります。 特定のケースでは、 com.example.annotation.HashedAnnotationProcessor 。 それだけです。 現在、注釈とそのプロセッサを含むライブラリを構築しています。 このライブラリをプロジェクトに接続します。 そしてそれは動作します。

同時に、ライブラリ自体も注釈の残りも、コンパイルされたコードに分類されません。



使用する


アブストラクトの準備ができました。 文字列はハッシュされます。 それはまだ問題が解決されていないだけです。

このフォームで注釈をプロジェクトに接続すると、行は常にハッシュされます。 そして、リリースでのみ必要です。

Javaでは、デバッグとリリースビルドの概念は非常にarbitrary意的であり、ユーザーのビューに依存します。 したがって、AndroidプロジェクトのassembleDebugタスクが文字列をハッシュしないようにし、他のすべての場合はMD5ハッシュが文字列から残るようにします。

この問題を解決するために、追加のパラメーターを注釈プロセッサーに渡します。

まず、プロセッサを変更します。

 @SupportedOptions({"Hashed"}) public class HashedAnnotationProcessor extends AbstractProcessor { private boolean enable = true; @Override public void init(ProcessingEnvironment procEnv) { //  java.util.Map<java.lang.String,java.lang.String> opt = javacProcessingEnv.getOptions(); if (opt.containsKey(ENABLE_OPTIONS_NAME) && opt.get(ENABLE_OPTIONS_NAME).equals("disable")){ enable = false; } } @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { if (!enable){ javacProcessingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Annotation Hashed is disable"); return false; } //... } }
      
      





「ハッシュ」オプションを待っていることを発表し、「無効」の場合は何もせず、ユーザーに情報を表示します。 Diagnostic.Kind.NOTEのようなメッセージは情報であり、デフォルト設定では、多くの開発環境はこれらのメッセージをまったく表示しません。

同時に、注釈の処理を開始しなかったことをコンパイラーに通知します。 このタイプの注釈を処理するプロセッサーがシステムにまだある場合、またはタイプをまったく解析しない場合は、注釈を取得できます。 確かに、コンパイラが注釈を破棄しようとする順序については絶対に何も言えません。 これまでのところ、ライブラリとアノテーションは1つしかありません。これは関係ありませんが、アノテーションの複数のライブラリを使用する場合は、陥りやすい落とし穴に備えてください。

このオプションをコンパイラに渡すことは残っています。 プロセッサのオプションは、-Aスイッチを使用してコンパイラに渡されます。 この場合、「-AHashed = disable」。

残っているのは、Gradleにこのオプションを適切なタイミングで強制的に転送することです。 そしてまた松葉杖:

 tasks.withType(JavaCompile) { if (name == "compileDebug"){ options.compilerArgs << "-AHashed=disable" } }
      
      





これは、Android Studioの現在のバージョン用です。 以前のtasks.withType(コンパイル)の場合。

松葉杖。このブロックは、タスクに関係なく、アセンブリの種類ごとに呼び出されるためです。 理論的には、AndroidブロックのbuildTypesに似たものがあるはずですが、美しいソリューションを探す力はすでにありませんでした。 結局のところ、誰もがロシア語で文書が伝統的にないことをすでに推測しましたか?

コードでは、注釈は次のようになります。

  @Hashed public static final String demo1 = "habr"; @Hashed (method="SHA-1") public static final String demo2 = "habrahabr"; @Hashed(method="SHA-256") public static final String demo3 = "habracadabra";
      
      





メソッドは、サポートされているMessageDigestのいずれかです。



まとめ


問題は解決しました。 もちろん、最も効率的な方法ではなく、定数を宣言する1つの非常に具体的な方法についてのみですが、多くの場合、問題のステートメント自体が記事の資料よりも多くの質問を提起します。 そして、同様のタスクが彼の方法で満たされるなら、誰かがより少ない時間と神経を費やすことを望みます。

しかし、さらに私は誰かがこのトピックに興味を持ち、Habrrがこのすべての魔法が働く理由を説明する記事を見ることを願っています。

そして、もちろん、約束のコード: GitHub :: DemoAnnotation



All Articles